대여 & 플레이 기록 관리
처음 오셨나요? 회원가입 탭을 눌러주세요
-
🔍
📢 공지사항
async function subscribePush(){ if(_isIos && !_isStandalone){ showToast('📲 iOS는 홈 화면에 추가 후 앱으로 실행해야 알림 설정이 가능해요!','var(--yellow)'); return; } if(!('serviceWorker' in navigator) || !('PushManager' in window)){ if(_isIos && _isStandalone){ showToast('iOS 16.4 이상에서만 푸시 알림을 지원해요.','var(--red)'); return; } showToast('이 브라우저는 푸시 알림을 지원하지 않아요.','var(--red)'); return; } const perm = await Notification.requestPermission(); if(perm !== 'granted'){ showToast('알림 권한을 허용해주세요.','var(--red)'); return; } try{ let reg = await navigator.serviceWorker.getRegistration('/'); if(!reg) reg = await navigator.serviceWorker.register('/sw.js'); await Promise.race([ navigator.serviceWorker.ready, new Promise((_,rej)=>setTimeout(()=>rej(new Error('SW 타임아웃')),8000)) ]); let sub = await reg.pushManager.getSubscription(); if(!sub){ sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8(VAPID_PUB) }); } window._pushSub = sub; const json = sub.toJSON(); const res = await fetch(`${PUSH_WORKER}/subscribe`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ member_name: currentUser.name, endpoint: json.endpoint, p256dh: json.keys.p256dh, auth: json.keys.auth }) }); if(!res.ok) throw new Error('저장 실패: ' + await res.text()); showToast('🔔 알림이 설정되었습니다!','var(--green)'); subscribedNames.add(currentUser.name); renderPushBtn(); renderMembers(); }catch(e){ console.error('[Push] 에러:', e); showToast('알림 설정 실패: '+(e?.message||'알 수 없는 오류'),'var(--red)'); } } async function unsubscribePush(){ try{ await OneSignal.User.PushSubscription.optOut(); await OneSignal.logout(); showToast('🔕 알림이 해제되었습니다.','var(--muted)'); subscribedNames.delete(currentUser.name); renderPushBtn(); renderMembers(); }catch(e){ showToast('해제 실패: '+e.message,'var(--red)'); } } function urlB64ToUint8(b64){ const pad = '='.repeat((4-b64.length%4)%4); const raw = atob((b64+pad).replace(/-/g,'+').replace(/_/g,'/')); return Uint8Array.from(raw, c=>c.charCodeAt(0)); } function renderPushBtn(){ const isOn = window.OneSignal ? (window.OneSignal.Notifications.permission === true && window.OneSignal.User?.PushSubscription?.optedIn === true) : false; ['pushBtnMyInfo'].forEach(id=>{ const el=document.getElementById(id); if(!el) return; el.innerHTML = isOn ? `` : ``; }); } // 핸드폰 번호 입력 유도 function showPhonePrompt(){ openModal(`
📱
연락처를 등록해주세요!
게임 대여 시 연락을 위해
핸드폰 번호를 등록해 주세요.
`); } async function savePhonePrompt(){ const phone = formatPhone(document.getElementById('phonePromptInput').value.trim()); if(!phone){ showToast('번호를 입력해주세요.','var(--red)'); return; } try{ await sb('members','PATCH',{phone},`?id=eq.${currentUser.id}`); currentUser.phone = phone; localStorage.setItem('bgCurrentUser', JSON.stringify(currentUser)); closeDetailModal(); showToast('✅ 연락처가 등록되었습니다!','var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } // 로그인 후 알림 설정 유도 function showPushPrompt(){ openModal(`
🔔
알림을 받아보세요!
매칭 신청, 공지사항 등
새로운 소식을 바로 알려드려요.
`); } // 관리자: 알림 전송 async function sendPushNotification(title, body, target='all'){ try{ const res = await fetch(`${PUSH_WORKER}/send`,{ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title, body, target}) }); const data = await res.json(); showToast(`📨 ${data.sent}명에게 알림 전송!`,'var(--green)'); }catch(e){ showToast('알림 전송 실패','var(--red)'); } } function openPushSendModal(mode){ const title = document.getElementById('pushTitle')?.value.trim(); const body = document.getElementById('pushBody')?.value.trim(); if(!title){ showToast('알림 제목을 입력하세요.','var(--red)'); return; } if(!body) { showToast('알림 내용을 입력하세요.','var(--red)'); return; } if(mode==='all'){ if(!confirm(`전체 회원에게 알림을 전송할까요?\n\n제목: ${title}\n내용: ${body}`)) return; sendPushNotification(title, body, 'all'); } else { const opts = members.map(m=>` `).join(''); openModal(`
👤 알림 받을 회원 선택
제목: ${title}
${opts}
`); setTimeout(()=>{ const btn = document.getElementById('pushSelectBtn'); if(!btn) return; btn.onclick = async () => { const checked = [...document.querySelectorAll('#pushMemberList input:checked')].map(c=>c.value); if(!checked.length){ showToast('회원을 선택하세요.','var(--red)'); return; } btn.disabled = true; btn.textContent = '전송 중...'; await Promise.all(checked.map(name => sendPushNotification(title, body, name))); closeDetailModal(); }; }, 100); } } // 브라우저/기기 감지 const _ua = navigator.userAgent; const _isIos = /iphone|ipad|ipod/i.test(_ua); const _isAndroid = /android/i.test(_ua); const _isChrome = /chrome/i.test(_ua) && !/edge|edg/i.test(_ua); const _isEdge = /edge|edg/i.test(_ua); const _isSamsung = /samsungbrowser/i.test(_ua); const _isFirefox = /firefox/i.test(_ua); const _isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches; // 배너 표시/숨김 헬퍼 - 내 정보 탭만 제어 function _showBanners(type){ // type: 'install' | 'ios' | 'manual' | 'hide' const installIds = ['myInfoInstallBtn']; const iosIds = ['myInfoIosBtn']; const manualIds = ['myInfoManualBtn']; const all = [...installIds,...iosIds,...manualIds]; all.forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='none'; }); if(type==='install') installIds.forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='flex'; }); if(type==='ios') iosIds.forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='block'; }); if(type==='manual') manualIds.forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='block'; }); } // 안드로이드 크롬/엣지/삼성: beforeinstallprompt 지원 // 안드로이드 크롬/엣지/삼성: beforeinstallprompt 지원 window.addEventListener('beforeinstallprompt', e=>{ e.preventDefault(); _installPrompt = e; if(!_isStandalone) _showBanners('install'); }); window.addEventListener('appinstalled', ()=>{ _installPrompt = null; _showBanners('hide'); showToast('✅ 홈 화면에 추가되었습니다!','var(--green)'); }); function triggerInstall(){ if(_installPrompt){ _installPrompt.prompt(); _installPrompt.userChoice.then(r=>{ if(r.outcome==='accepted') _installPrompt=null; }); } } // 기기/브라우저별 안내 결정 function checkInstallSupport(){ if(_isStandalone){ // 이미 설치된 상태 표시 ['myInfoInstallBtn','myInfoIosBtn','myInfoManualBtn'].forEach(id=>{ const el=document.getElementById(id); if(el) el.style.display='none'; }); const el=document.getElementById('myInfoInstallBtn'); if(el){ el.style.display='flex'; el.style.background='var(--surface)'; el.style.border='1px solid var(--border)'; el.style.cursor='default'; el.onclick=null; el.innerHTML=`
홈 화면에 설치됨
앱으로 실행 중
`; } return; } // iOS Safari → 공유버튼 안내 if(_isIos){ _showBanners('ios'); return; } // beforeinstallprompt 이미 캡처됨 (안드로이드 크롬/엣지/삼성) if(_installPrompt){ _showBanners('install'); return; } // 파이어폭스 안드로이드 if(_isFirefox && _isAndroid){ _setManualGuide('firefox'); _showBanners('manual'); return; } // 안드로이드인데 지원 브라우저 아닌 경우 if(_isAndroid && !_isChrome && !_isEdge && !_isSamsung){ _setManualGuide('other-android'); _showBanners('manual'); return; } // 크롬/엣지/삼성인데 아직 prompt 안 온 경우 (조건 충족 대기 중) if(_isAndroid){ const el=document.getElementById('myInfoInstallBtn'); if(el){ el.style.display='flex'; el.innerHTML=`
📲
홈 화면에 추가하기
크롬 메뉴 → 앱 설치 또는 홈 화면에 추가
`; } } } function _setManualGuide(type){ const guides = { 'firefox': '파이어폭스에서는 주소창 오른쪽 ⋮ 메뉴 → 홈 화면에 추가를 탭하세요.', 'other-android': '크롬 브라우저에서 열면 홈 화면에 설치할 수 있어요.
chrome:// 으로 접속하거나 크롬 앱에서 이 주소를 열어보세요.' }; const msg = guides[type] || '브라우저 메뉴에서 "홈 화면에 추가"를 찾아보세요.'; ['manualBanner','myInfoManualBtn','noticeManualBanner'].forEach(id=>{ const el=document.getElementById(id); if(el){ const txt=el.querySelector('.manual-text'); if(txt) txt.innerHTML=msg; } }); } setTimeout(checkInstallSupport, 600); window.addEventListener('load', ()=>{ loadLocal(); const saved = localStorage.getItem('bgCurrentUser'); if(saved){ try{ currentUser = JSON.parse(saved); startApp(); }catch{ localStorage.removeItem('bgCurrentUser'); } } }); // ── 로그인 ── async function doLogin(){ const name = document.getElementById('l_name').value.trim(); const pw = document.getElementById('l_pw').value; if(!name){ showToast('이름을 입력하세요.','var(--red)'); return; } if(!pw){ showToast('비밀번호를 입력하세요.','var(--red)'); return; } if(isDemo()){ const member = members.find(m=>m.name===name); if(!member){ showToast('가입된 회원이 아님. 회원가입 탭을 이용하세요.','var(--yellow)'); return; } if(member.pw !== pw){ showToast('비밀번호가 틀렸습니다.','var(--red)'); return; } currentUser = {...member}; } else { try{ const hash = await sha256(pw); const res = await sb('members','GET',null, `?name=eq.${encodeURIComponent(name)}&password_hash=eq.${hash}`); if(!res||!res.length){ showToast('이름 또는 비밀번호가 틀렸습니다.','var(--red)'); return; } const m = res[0]; currentUser = {id:m.id, name:m.name, region:m.region, dept:m.department, phone:m.phone||'', isAdmin:m.is_admin}; }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } localStorage.setItem('bgCurrentUser', JSON.stringify(currentUser)); startApp(); } // ── 회원가입 ── async function doRegister(){ const name = document.getElementById('r_name').value.trim(); const region = document.getElementById('r_region').value; const dept = document.getElementById('r_dept').value.trim(); const phone = formatPhone(document.getElementById('r_phone').value.trim()); const pw = document.getElementById('r_pw').value; const pw2 = document.getElementById('r_pw2').value; const adminPw = document.getElementById('r_adminPw').value; if(!name){ showToast('이름을 입력하세요.','var(--red)'); return; } if(!region){ showToast('지역을 선택하세요.','var(--red)'); return; } if(!dept){ showToast('부서를 입력하세요.','var(--red)'); return; } if(!pw){ showToast('비밀번호를 입력하세요.','var(--red)'); return; } if(pw !== pw2){ showToast('비밀번호가 일치하지 않습니다.','var(--red)'); return; } const isAdmin = adminPw === adminPassword; if(isDemo()){ if(members.find(m=>m.name===name)){ showToast('이미 가입된 이름입니다.','var(--yellow)'); return; } const member = { id: Date.now().toString(), name, region, dept, phone, pw, isAdmin, joinedAt: today() }; members.push(member); localStorage.setItem('bgMembers', JSON.stringify(members)); currentUser = {...member}; } else { try{ const dup = await sb('members','GET',null,`?name=eq.${encodeURIComponent(name)}`); if(dup&&dup.length){ showToast('이미 가입된 이름입니다.','var(--yellow)'); return; } const hash = await sha256(pw); const res = await sb('members','POST',{name, region, department:dept, phone:phone||null, password_hash:hash, is_admin:isAdmin}); const m = res?.[0]; currentUser = {id:m.id, name:m.name, region:m.region||'', dept:m.department||'', phone:m.phone||'', isAdmin:m.is_admin}; }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } localStorage.setItem('bgCurrentUser', JSON.stringify(currentUser)); showToast(isAdmin ? '👑 관리자로 가입 완료!' : '✅ 가입 완료!', 'var(--green)'); startApp(); } // ── 링크 공유 ── function shareLink(){ const url = location.href.split('?')[0].split('#')[0]; document.getElementById('shareLinkInput').value = url; document.getElementById('qrContainer').style.display = 'none'; document.getElementById('qrCode').innerHTML = ''; document.getElementById('copyBtn').textContent = '복사'; document.getElementById('shareModal').classList.add('open'); } function copyShareLink(){ const input = document.getElementById('shareLinkInput'); const url = input.value; const btn = document.getElementById('copyBtn'); const onSuccess = () => { btn.textContent = '✅ 복사됨'; setTimeout(()=>{ btn.textContent = '복사'; }, 2000); }; // 최신 API 시도 if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(url).then(onSuccess).catch(()=> fallbackCopy(input, onSuccess)); } else { fallbackCopy(input, onSuccess); } } function fallbackCopy(input, onSuccess){ // iOS Safari 폴백: range + selection input.contentEditable = true; input.readOnly = false; const range = document.createRange(); range.selectNodeContents(input); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); input.setSelectionRange(0, 999999); try { document.execCommand('copy'); onSuccess(); } catch(e){ showToast('직접 복사해주세요', 'var(--yellow)'); } input.contentEditable = false; input.readOnly = true; sel.removeAllRanges(); } function nativeShare(){ const url = document.getElementById('shareLinkInput').value; if(navigator.share){ navigator.share({ title:'전남미플', text:'전남미플 앱에 접속하세요!', url }) .catch(()=>{}); } else { showToast('이 브라우저는 공유 기능을 지원하지 않습니다.','var(--yellow)'); } } function generateQR(){ const url = document.getElementById('shareLinkInput').value; const container = document.getElementById('qrContainer'); const qrDiv = document.getElementById('qrCode'); qrDiv.innerHTML = ''; if(typeof QRCode === 'undefined'){ const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js'; script.onload = ()=>{ _makeQR(url, qrDiv, container); }; document.head.appendChild(script); } else { _makeQR(url, qrDiv, container); } } function _makeQR(url, qrDiv, container){ new QRCode(qrDiv, { text: url, width: 180, height: 180, colorDark:'#1a1828', colorLight:'#ffffff', correctLevel: QRCode.CorrectLevel.M }); container.style.display = 'block'; } // ── 로그아웃 ── function doLogout(){ localStorage.removeItem('bgCurrentUser'); currentUser = null; document.getElementById('appScreen').classList.remove('active'); document.getElementById('loginScreen').classList.add('active'); document.getElementById('l_name').value=''; document.getElementById('l_pw').value=''; } // ── 앱 시작 ── function startApp(){ setTimeout(updateTabFade, 100); // OneSignal 로그인 (이름으로 External ID 설정) - 이미 구독된 경우에도 적용 if(window.OneSignal && currentUser?.name){ OneSignal.login(currentUser.name).catch(()=>{}); } document.getElementById('loginScreen').classList.remove('active'); document.getElementById('appScreen').classList.add('active'); document.getElementById('userChip').textContent = `👤 ${currentUser.name}${currentUser.isAdmin?'(관)':''} ${currentUser.region?'['+currentUser.region+']':''}`; // 관리자 탭·기능 표시 document.querySelectorAll('.admin-only').forEach(el=>el.style.display=currentUser.isAdmin?'':'none'); // 회원 탭은 모두 볼 수 있음 const memberTabBtn = document.querySelector('.tab[onclick*="switchTab(\'members\')"]'); if(memberTabBtn) memberTabBtn.style.display=''; // 공지 작성 버튼 (관리자만) const btnNotice = document.getElementById('btnWriteNotice'); if(btnNotice) btnNotice.style.display = currentUser.isAdmin ? '' : 'none'; // 필터 초기 active: 대여가능 currentFilter = 'available'; document.querySelectorAll('#gameFilterRow .chip').forEach((c,i)=>c.classList.toggle('active', i===0)); initVotes(); if(isDemo()){ loadDemo(); switchTab('notices'); } else { fetchAll().then(()=>{ renderMatchingList(); }); } renderGameFilterChips(); switchTab('notices'); // 로그인 후 알림 설정 안내 (처음 로그인 시만) const pushDismissed = localStorage.getItem('pushDismissed_'+currentUser.name); if(!pushDismissed && 'Notification' in window && Notification.permission === 'default'){ setTimeout(()=>showPushPrompt(), 1000); } // 핸드폰 번호 없는 경우 입력 유도 if(!currentUser.phone){ setTimeout(()=>showPhonePrompt(), pushDismissed ? 500 : 2500); } } // ── 탭 전환 ── // ── 모달 스와이프 다운 닫기 ── document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.modal-overlay').forEach(overlay => { const modal = overlay.querySelector('.modal'); if(!modal) return; let startY = 0, curY = 0, active = false; modal.addEventListener('touchstart', e => { if(modal.scrollTop > 0) return; startY = e.touches[0].clientY; active = true; modal.style.transition = 'none'; }, {passive:true}); modal.addEventListener('touchmove', e => { if(!active) return; curY = e.touches[0].clientY; const dy = curY - startY; if(dy > 0 && modal.scrollTop <= 0){ modal.style.transform = `translateY(${Math.min(dy, 200)}px)`; } else { active = false; modal.style.transform = ''; } }, {passive:true}); modal.addEventListener('touchend', e => { if(!active) return; active = false; const dy = curY - startY; modal.style.transition = 'transform .25s ease'; if(dy > 100){ modal.style.transform = 'translateY(110%)'; setTimeout(() => { overlay.classList.remove('open'); modal.style.transform = ''; modal.style.transition = ''; }, 250); } else { modal.style.transform = ''; setTimeout(() => { modal.style.transition = ''; }, 250); } }, {passive:true}); }); }); function updateTabFade(){ const tabs = document.getElementById('mainTabs'); const left = document.getElementById('tabFadeLeft'); const right = document.getElementById('tabFadeRight'); if(!tabs||!left||!right) return; left.style.opacity = tabs.scrollLeft > 10 ? '1' : '0'; right.style.opacity = tabs.scrollLeft < tabs.scrollWidth - tabs.clientWidth - 10 ? '1' : '0'; } function switchTab(tab){ const tabs=['notices','matching','games','playstats','purchase','myinfo','admin','members','upload']; tabs.forEach(t=>{ const el=document.getElementById('tab-'+t); if(el) el.style.display=t===tab?'block':'none'; }); document.querySelectorAll('.tab').forEach(el=>{ el.classList.toggle('active', el.getAttribute('onclick')===`switchTab('${tab}')`); }); // 탭 전환 시 데이터 새로고침 (demo 모드 제외) if(!isDemo()){ fetchAll().then(()=>{ renderMatchingList(); _renderTab(tab); }); } else { _renderTab(tab); } // FAB 탭별 설정 const fab = document.getElementById('fabBtn'); if(!fab) return; fab.style.display = 'none'; fab.onclick = null; if(tab==='games' && currentUser.isAdmin){ fab.style.display = ''; fab.textContent = '+'; fab.onclick = openAddModal; } else if(tab==='playstats'){ fab.style.display = ''; fab.textContent = '+'; fab.onclick = openPlaySelectModal; } else if(tab==='admin' && currentUser.isAdmin){ fab.style.display = ''; fab.textContent = '+'; fab.onclick = openAddModal; } } function _renderTab(tab){ if(tab==='admin') renderAdmin(); if(tab==='members') fetchPushSubscribers().then(()=>renderMembers()); if(tab==='playstats') renderPlayStats(); if(tab==='purchase') renderPurchaseList(); if(tab==='notices') renderNoticeList(); if(tab==='matching') renderMatchingList(); if(tab==='myinfo'){ renderMyInfo(); renderMyRentals(); } } function setGameSort(sort){ currentGameSort = sort; document.getElementById('sortNameBtn')?.classList.toggle('active', sort==='name'); document.getElementById('sortPlayBtn')?.classList.toggle('active', sort==='plays'); document.getElementById('sortLocBtn')?.classList.toggle('active', sort==='location'); renderGames(); } function setFilter(f,el){ currentFilter = (currentFilter === f) ? 'all' : f; document.querySelectorAll('#gameFilterRow .chip').forEach(c=>c.classList.remove('active')); if(currentFilter !== 'all') el.classList.add('active'); renderGames(); } let currentMemberFilter = 'all'; function setMemberFilter(f, el){ currentMemberFilter = f; document.querySelectorAll('#tab-members .filter-row .chip').forEach(c=>c.classList.remove('active')); el.classList.add('active'); renderMembers(); } // ── Supabase 데이터 Fetch ── async function fetchGames(){ try{ document.getElementById('gameList').innerHTML='
불러오는 중...
'; const data = await sb('games','GET',null,'?order=name'); games = (data||[]).map(g=>({ ...g, renterName: g.renter_name||'', rentalEndDate: g.rental_end_date||'', rentalStartDate: g.rental_start||'', })); renderGames(); renderGameFilterChips(); }catch(e){ document.getElementById('gameList').innerHTML='
⚠️

연결 오류

'; } } async function fetchMembers(){ try{ const data = await sb('members','GET',null,'?order=name'); members = (data||[]).map(m=>({...m, dept:m.department, isAdmin:m.is_admin})); }catch(e){ console.error('회원 로드 오류',e); } } async function fetchSubscribedNames(){ try{ const data = await sb('push_subscriptions','GET',null,'?select=member_name'); subscribedNames = new Set((data||[]).map(s=>s.member_name)); }catch(e){ subscribedNames = new Set(); } } async function fetchPlayRecords(){ try{ const data = await sb('play_records','GET',null,'?order=played_date.desc'); playRecords = {}; (data||[]).forEach(r=>{ if(!playRecords[r.game_id]) playRecords[r.game_id]=[]; let players, memo = r.memo||''; const scoreMatch = memo.match(/\|\|scores:(\[.*\])$/s); if(scoreMatch){ try{ players = JSON.parse(scoreMatch[1]); }catch(e){ players = null; } memo = memo.replace(/\|\|scores:\[.*\]$/,'').trim(); } if(!players) players = (r.players||[]).map(p=>({name:p, score:'0'})); playRecords[r.game_id].push({ id:r.id, date:r.played_date, players, memo, recordedBy:r.recorded_by, gameId:r.game_id, gameName:r.game_name }); }); }catch(e){ console.error('플레이기록 로드 오류',e); } } async function fetchVotesFromSB(){ try{ const data = await sb('votes','GET',null,'?order=created_at.desc'); votes = (data||[]).map(v=>({ ...v, creatorName:v.created_by_name, closed:v.is_closed, desc:v.description||'', options:(v.options||[]).filter(o=>o.id!=='__abstain__').map(o=>({...o, voters:o.votes||[]})), abstainers:((v.options||[]).find(o=>o.id==='__abstain__')||{}).votes||[] })); renderNoticeList(); }catch(e){ console.error('투표 로드 오류',e); } } // ── 데모 데이터 ── function loadDemo(){ games=[ {id:'1',name:'카탄',genre:'전략',players:'3~4명',location:'거실 책장 2단',memo:'',status:'available',renterName:'',renterPhone:'',rentalStartDate:'',rentalEndDate:'',rentalMemo:''}, {id:'2',name:'코드네임',genre:'파티',players:'4~8명',location:'거실 책장 2단',memo:'카드 2세트',status:'rented',renterName:currentUser.name,renterPhone:'',rentalStartDate:'2026-03-01',rentalEndDate:'2026-03-20',rentalMemo:''}, {id:'3',name:'아줄',genre:'전략',players:'2~4명',location:'서재 박스A',memo:'',status:'available',renterName:'',renterPhone:'',rentalStartDate:'',rentalEndDate:'',rentalMemo:''}, {id:'4',name:'스플렌더',genre:'경제',players:'2~4명',location:'서재 박스A',memo:'',status:'rented',renterName:'김철수',renterPhone:'',rentalStartDate:'2026-03-05',rentalEndDate:'2026-03-08',rentalMemo:''}, ]; if(!playRecords['1']) playRecords['1']=[ {date:'2026-03-01',players:[{name:'홍길동',score:10},{name:'김철수',score:8},{name:'이영희',score:12}],memo:'첫 플레이'}, {date:'2026-03-08',players:[{name:'홍길동',score:7},{name:'박민수',score:11}],memo:''}, ]; renderGames(); renderGameFilterChips(); } // ── 게임 목록 렌더 ── function renderGames(){ const q = document.getElementById('searchInput').value.toLowerCase().trim(); const stype = document.getElementById('searchType')?.value || 'name'; let list = games.filter(g=>{ if(!q) return true; if(stype==='name') return g.name.toLowerCase().includes(q); if(stype==='location') return (g.location||'').toLowerCase().includes(q); if(stype==='genre') return (g.genre||'').toLowerCase().includes(q); if(stype==='renter') return g.status==='rented' && (g.renterName||'').toLowerCase().includes(q); return g.name.toLowerCase().includes(q); }); if(currentFilter==='available') list=list.filter(g=>g.status!=='rented'); else if(currentFilter==='rented') list=list.filter(g=>g.status==='rented'&&!isOverdue(g)); else if(currentFilter==='overdue') list=list.filter(g=>isOverdue(g)); // 정렬 if(currentGameSort==='plays'){ list.sort((a,b)=>(playRecords[b.id]||[]).length-(playRecords[a.id]||[]).length||a.name.localeCompare(b.name,'ko')); } else if(currentGameSort==='location'){ list.sort((a,b)=>(a.location||'').localeCompare(b.location||'','ko')||a.name.localeCompare(b.name,'ko')); } else { list.sort((a,b)=>a.name.localeCompare(b.name,'ko')); } const el=document.getElementById('gameList'); if(!list.length){el.innerHTML='
🎲

게임이 없음.

';return;} el.innerHTML=list.map(g=>{ const st=getStatus(g); const isMyRental=g.renterName===currentUser.name; const plays=(playRecords[g.id]||[]).length; const renterHtml=g.status==='rented'?`
👤 ${g.renterName}${isMyRental?'내 대여':''} ${isOverdue(g)?'⚠ '+Math.abs(daysLeft(g.rentalEndDate))+'일 연체':g.rentalEndDate?'D-'+daysLeft(g.rentalEndDate):''}
`:''; return`
${g.name} 🔗
${st==='available'?'대여가능':st==='overdue'?'⚠연체':'대여중'}
${g.genre?`${g.genre}`:''} ${g.players?`👥 ${g.players}`:''} ${plays?`🎮 ${plays}회`:''}
${g.location?`
📦 ${g.location}
`:''} ${renterHtml}
`; }).join(''); } // ── 내 대여 ── function renderMyRentals(){ const mine=games.filter(g=>g.renterName===currentUser.name&&g.status==='rented'); const el=document.getElementById('myRentalList'); if(!mine.length){el.innerHTML='

대여중인 게임 없음.

';return;} el.innerHTML=mine.map(g=>{ const od=isOverdue(g); return`
${g.name}
${g.rentalEndDate?(od?`⚠ ${Math.abs(daysLeft(g.rentalEndDate))}일 연체`:`반납예정 D-${daysLeft(g.rentalEndDate)} (${g.rentalEndDate})`):'-'}
📦 ${g.location||'-'}
`; }).join(''); } // ── 관리자 ── function renderAdmin(){ const rented=games.filter(g=>g.status==='rented'); const od=games.filter(g=>isOverdue(g)); document.getElementById('stTotal').textContent=games.length; document.getElementById('stAvail').textContent=games.filter(g=>g.status!=='rented').length; document.getElementById('stRented').textContent=rented.length; document.getElementById('stOverdue').textContent=od.length; const el=document.getElementById('adminList'); if(!rented.length){el.innerHTML='

대여중인 게임 없음.

';} else{ el.innerHTML=rented.map(g=>{ const isOd=isOverdue(g); return`
${g.name}
${isOd?'⚠연체':'대여중'}
👤 ${g.renterName}
📅 ${g.rentalStartDate||'-'} → ${g.rentalEndDate||'-'} ${isOd?' (⚠'+Math.abs(daysLeft(g.rentalEndDate))+'일 연체)':g.rentalEndDate?' (D-'+daysLeft(g.rentalEndDate)+')':''}
`; }).join(''); } renderPlayStats(); } // ── 회원 관리 ── function renderMembers(){ const q = (document.getElementById('memberSearchInput')?.value||'').toLowerCase(); let list = members.filter(m=>{ const matchQ = !q || m.name.toLowerCase().includes(q) || (m.dept||'').toLowerCase().includes(q); if(!matchQ) return false; if(currentMemberFilter==='all') return true; if(currentMemberFilter==='admin') return m.isAdmin; return m.region === currentMemberFilter; }); document.getElementById('memberCount').textContent=list.length+'/'+members.length; const el=document.getElementById('memberList'); if(!list.length){el.innerHTML='
👥

회원이 없거나 검색 결과가 없음.

';return;} el.innerHTML=list.map(m=>`
${m.name} ${m.isAdmin?'관리자':''}
🏢 ${m.region ? m.region+' · ' : ''}${m.dept}
${m.phone?`
📱 ${m.phone}
`:''}
${currentUser.isAdmin ? `
${m.id !== currentUser.id ? (m.isAdmin ? `` : ``) : ''}
` : ''}
`).join(''); } function downloadMembersExcel(){ const rows = [['이름','지역','부서','연락처','권한','가입일']]; members.forEach(m=>{ rows.push([ m.name, m.region||'', m.dept||'', m.phone||'', m.isAdmin?'관리자':'일반', m.joinedAt||'' ]); }); // CSV (UTF-8 BOM) → 엑셀에서 한글 깨짐 방지 const bom = '\uFEFF'; const csv = bom + rows.map(r=>r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n'); const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `전남미플_회원목록_${today()}.csv`; a.click(); URL.revokeObjectURL(url); showToast('📥 회원 목록 다운로드 완료!','var(--green)'); } async function toggleAdmin(id){ const m = members.find(x=>x.id===id); if(!m) return; const newVal = !m.isAdmin; // 박탈 시 관리자 코드 확인 if(!newVal){ const code = prompt('관리자 코드를 입력하세요:'); if(code === null) return; // 취소 if(code !== adminPassword){ showToast('관리자 코드가 틀렸습니다.','var(--red)'); return; } } if(!isDemo()){ try{ await sb('members','PATCH',{is_admin:newVal},`?id=eq.${id}`); m.isAdmin = newVal; // 목록 다시 불러와서 갱신 await fetchMembers(); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } else { m.isAdmin = newVal; localStorage.setItem('bgMembers', JSON.stringify(members)); } renderMembers(); showToast(`${m.name} 관리자 ${newVal?'부여':'박탈'} 완료`, 'var(--green)'); } // ── 관리자: 회원 비밀번호 초기화 ── function openInstallGuide(){ // Android: 설치 프롬프트 있으면 자동 설치 시도 if(_installPrompt){ triggerInstall(); return; } // 이미 설치된 경우 if(_isStandalone){ showToast('✅ 이미 앱으로 실행 중이에요!','var(--green)'); return; } // iOS 또는 기타: 안내 모달 document.getElementById('installGuideModal').classList.add('open'); } async function resetMemberPw(id, name){ if(!currentUser.isAdmin){ showToast('관리자만 초기화할 수 있습니다.','var(--red)'); return; } const code = prompt('관리자 코드를 입력하세요:'); if(code === null) return; if(code !== adminPassword){ showToast('관리자 코드가 틀렸습니다.','var(--red)'); return; } if(!confirm(`${name}님의 비밀번호를 0000으로 초기화할까요?`)) return; try{ const hash = await sha256('0000'); await sb('members','PATCH',{password_hash:hash},`?id=eq.${id}`); showToast(`✅ ${name}님의 비밀번호가 0000으로 초기화됐어요.`,'var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } async function deleteMember(id){ if(!currentUser.isAdmin){ showToast('관리자만 삭제할 수 있습니다.','var(--red)'); return; } if(!confirm('회원을 삭제하시겠습니까?')) return; if(!isDemo()){ try{ await sb('members','DELETE',null,`?id=eq.${id}`); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } else { localStorage.setItem('bgMembers', JSON.stringify(members)); } members = members.filter(x=>x.id!==id); renderMembers(); showToast('🗑 회원 삭제', 'var(--muted)'); } // ── 비밀번호 변경 ── function openPwChangeModal(){ ['pw_cur','pw_new','pw_confirm'].forEach(id=>document.getElementById(id).value=''); document.getElementById('pwModal').classList.add('open'); } function changePassword(){ const cur=document.getElementById('pw_cur').value; const nw=document.getElementById('pw_new').value; const cf=document.getElementById('pw_confirm').value; if(cur!==adminPassword){showToast('현재 비밀번호가 틀림','var(--red)');return;} if(!nw){showToast('새 비밀번호를 입력하세요','var(--red)');return;} if(nw!==cf){showToast('새 비밀번호가 일치하지 않음','var(--red)');return;} adminPassword=nw; saveLocal(); closeById('pwModal'); showToast('✅ 비밀번호 변경 완료','var(--green)'); } // ── 플레이 기록 ── function renderGameFilterChips(){ // 검색 방식으로 변경 - 칩 불필요 } let currentPlayView = 'stats'; function setPlayView(view, btn){ currentPlayView = view; document.querySelectorAll('#tab-playstats .filter-row .chip').forEach(c=>c.classList.remove('active')); btn.classList.add('active'); document.getElementById('playViewStats').style.display = view==='stats' ? '' : 'none'; document.getElementById('playViewRecords').style.display = view==='records' ? '' : 'none'; document.getElementById('playViewMine').style.display = view==='mine' ? '' : 'none'; if(view==='stats') renderPlayStats(); if(view==='records') renderPlayRecordList(); if(view==='mine') renderMyPlayRecords(); } function renderPlayStats(){ const allRecords = []; games.forEach(g=>(playRecords[g.id]||[]).forEach(r=>allRecords.push({...r, gameName:g.name}))); allRecords.sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const content = document.getElementById('playStatsContent'); if(!allRecords.length){ content.innerHTML='
🎮

플레이 기록 없음.
게임 후 기록을 추가하세요!

'; return; } const wins={}, plays={}, scores={}, gameCount={}; allRecords.forEach(r=>{ if(!r.players?.length) return; gameCount[r.gameName]=(gameCount[r.gameName]||0)+1; const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); const winners=getWinners(r.players); const winner=winners[0]; r.players.forEach(p=>{ plays[p.name]=(plays[p.name]||0)+1; scores[p.name]=(scores[p.name]||0)+Number(p.score||0); }); winners.forEach(w=>{ wins[w]=(wins[w]||0)+1; }); }); const top5 = Object.entries(plays) .map(([name,cnt])=>({name, plays:cnt, wins:wins[name]||0, avg:Math.round((scores[name]||0)/cnt), rate:Math.round(((wins[name]||0)/cnt)*100)})) .sort((a,b)=>b.wins-a.wins||b.plays-a.plays).slice(0,5); const top5game = Object.entries(gameCount).sort((a,b)=>b[1]-a[1]).slice(0,5); const maxWins = top5[0]?.wins||1; const maxPlays = Math.max(...top5.map(p=>p.plays),1); const maxGame = top5game[0]?.[1]||1; const COLORS = ['var(--accent)','var(--blue)','var(--green)','var(--yellow)','var(--red)']; const MEDALS = ['🥇','🥈','🥉','4위','5위']; // 모임일수 = 플레이 날짜 unique 수 const meetDays = new Set(allRecords.map(r=>r.date)).size; content.innerHTML = `
${allRecords.length}
총 플레이
${meetDays}
모임일수
${Object.keys(plays).length}
참가 인원
${Object.keys(gameCount).length}
플레이 게임
🏆 승리 횟수 TOP 5
전체보기 ▶
${top5.map((p,i)=>`
${MEDALS[i]} ${p.name}
${p.wins}승 / ${p.plays}판
`).join('')}
🎮 플레이 횟수 TOP 5
전체보기 ▶
${[...top5].sort((a,b)=>b.plays-a.plays).map((p,i)=>`
${MEDALS[i]} ${p.name}
${p.plays}판 · 승률 ${p.rate}% · 평균 ${p.avg}점
`).join('')}
📊 승률 TOP 5
전체보기 ▶
${[...top5].sort((a,b)=>b.rate-a.rate).map((p,i)=>`
${p.rate}%
${p.name}
${p.wins}승/${p.plays}판
`).join('')}
🎲 인기 게임 TOP 5
전체보기 ▶
${top5game.map(([name,cnt],i)=>`
${MEDALS[i]} ${name}
${cnt}회
`).join('')}
`; } // 전체 랭킹 모달 function openFullRank(type){ const allRecords = []; games.forEach(g=>(playRecords[g.id]||[]).forEach(r=>allRecords.push({...r, gameName:g.name}))); const wins={}, plays={}, scores={}, gameCount={}; allRecords.forEach(r=>{ if(!r.players?.length) return; gameCount[r.gameName]=(gameCount[r.gameName]||0)+1; const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); const winners=getWinners(r.players); const winner=winners[0]; r.players.forEach(p=>{ plays[p.name]=(plays[p.name]||0)+1; scores[p.name]=(scores[p.name]||0)+Number(p.score||0); }); winners.forEach(w=>{ wins[w]=(wins[w]||0)+1; }); }); const all = Object.keys(plays).map(name=>({ name, plays:plays[name], wins:wins[name]||0, avg: Math.round((scores[name]||0)/plays[name]), rate: Math.round(((wins[name]||0)/plays[name])*100) })); const MEDALS = ['🥇','🥈','🥉']; let title, sorted, rowFn; if(type==='wins'){ title = '🏆 승리 횟수 전체 순위'; sorted = [...all].sort((a,b)=>b.wins-a.wins||b.plays-a.plays); rowFn = (p,i) => `
${MEDALS[i]||`${i+1}.`} ${p.name}
${p.wins}승 / ${p.plays}판
승률 ${p.rate}%
`; } else if(type==='plays'){ title = '🎮 플레이 횟수 전체 순위'; sorted = [...all].sort((a,b)=>b.plays-a.plays); rowFn = (p,i) => `
${MEDALS[i]||`${i+1}.`} ${p.name}
${p.plays}판 · ${p.wins}승
승률 ${p.rate}% · 평균 ${p.avg}점
`; } else if(type==='rate'){ title = '📊 승률 전체 순위 (2판 이상)'; sorted = [...all].filter(p=>p.plays>=2).sort((a,b)=>b.rate-a.rate||b.wins-a.wins); rowFn = (p,i) => `
${MEDALS[i]||`${i+1}.`} ${p.name}
승률 ${p.rate}%
${p.wins}승 / ${p.plays}판
`; } else { title = '🎲 인기 게임 전체 순위'; const gameList = Object.entries(gameCount).sort((a,b)=>b[1]-a[1]); openModal(`
${title}
${gameList.map(([name,cnt],i)=>`
${MEDALS[i]||`${i+1}.`} ${name}
${cnt}회 ▶
`).join('')}
`); return; } openModal(`
${title}
${sorted.map((p,i)=>rowFn(p,i)).join('')}
`); } // 다중 승자 지원: winner 필드 우선, 없으면 최고점자 function fmtDeadline(dl){ if(!dl) return ''; const d = new Date(dl); if(isNaN(d)) return dl; const mm = String(d.getMonth()+1).padStart(2,'0'); const dd = String(d.getDate()).padStart(2,'0'); const hh = String(d.getHours()).padStart(2,'0'); const mi = String(d.getMinutes()).padStart(2,'0'); return `${d.getFullYear()}-${mm}-${dd} ${hh}:${mi}`; } function getWinners(players){ if(!players?.length) return []; const marked = players.filter(p=>p.winner===true||p.winner==='true'||p.winner===1); if(marked.length) return marked.map(p=>p.name); const maxScore = Math.max(...players.map(p=>Number(p.score||0))); return players.filter(p=>Number(p.score||0)===maxScore).map(p=>p.name); } // 플레이어 상세 모달 function openPlayerDetail(name){ const allRecords = []; games.forEach(g=>(playRecords[g.id]||[]).forEach(r=>allRecords.push({...r, gameName:g.name}))); const myRecs = allRecords.filter(r=>r.players?.some(p=>p.name===name)) .sort((a,b)=>(b.date||'').localeCompare(a.date||'')); let wins=0, totalScore=0, gameCount={}; myRecs.forEach(r=>{ const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); if(sorted[0]?.name===name) wins++; const me=r.players.find(p=>p.name===name); if(me) totalScore+=Number(me.score||0); gameCount[r.gameName]=(gameCount[r.gameName]||0)+1; }); const plays=myRecs.length, rate=plays?Math.round((wins/plays)*100):0, avg=plays?Math.round(totalScore/plays):0; const favGame=Object.entries(gameCount).sort((a,b)=>b[1]-a[1])[0]; const records = myRecs.map(r=>{ const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); const winners=getWinners(r.players); const winner=winners[0]; const isWin=winners.includes(name); const myScore=r.players.find(p=>p.name===name)?.score||'0'; return`
${r.date} · ${r.gameName}
${isWin?'🥇 승':'패'}
내 점수: ${myScore}점
${r.players.map(p=>`${p.name}: ${p.score}점${winners.includes(p.name)?' 👑':''}`).join('')}
`; }).join('') || '
기록 없음
'; openModal(`
👤 ${name}
${plays}
플레이
${wins}
승리
${rate}%
승률
${avg}
평균점
${favGame?`
🎲 최다 플레이: ${favGame[0]} (${favGame[1]}회)
`:''}
📅 전체 기록 (${plays}건)
${records}
`); } // 게임 상세 모달 function openGameDetail(gameName){ const allRecords = []; games.forEach(g=>(playRecords[g.id]||[]).forEach(r=>allRecords.push({...r, gameName:g.name}))); const gameRecs = allRecords.filter(r=>r.gameName===gameName) .sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const playerStats={}; gameRecs.forEach(r=>{ if(!r.players?.length) return; const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); const winners=getWinners(r.players); const winner=winners[0]; r.players.forEach(p=>{ if(!playerStats[p.name]) playerStats[p.name]={plays:0,wins:0,score:0}; playerStats[p.name].plays++; playerStats[p.name].score+=Number(p.score||0); if(winners.includes(p.name)) playerStats[p.name].wins++; }); }); const rankRows=Object.entries(playerStats) .map(([n,s])=>({name:n,...s,rate:Math.round((s.wins/s.plays)*100),avg:Math.round(s.score/s.plays)})) .sort((a,b)=>b.wins-a.wins||b.plays-a.plays); const records=gameRecs.map(r=>{ const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); const winners=getWinners(r.players); const winner=winners[0]; return`
${r.date}
${r.players.map(p=>`${p.name}: ${p.score}점${winners.includes(p.name)?' 👑':''}`).join('')}
${r.memo?`
${r.memo}
`:''}
`; }).join('')||'
기록 없음
'; openModal(`
🎲 ${gameName}
총 ${gameRecs.length}회 플레이
🏆 플레이어 순위
${rankRows.map((p,i)=>`
${['🥇','🥈','🥉'][i]||i+1+'. '} ${p.name}
${p.wins}승/${p.plays}판 · 승률${p.rate}% · 평균${p.avg}점
`).join('')}
📅 전체 기록
${records}
`); } function renderMyPlayRecords(){ const me = currentUser.name; const allRecords = []; games.forEach(g=>(playRecords[g.id]||[]).forEach(r=>allRecords.push({...r, gameName:g.name}))); // 내가 참가한 기록만 const myRecords = allRecords.filter(r=>r.players?.some(p=>p.name===me)); myRecords.sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const el = document.getElementById('playMineContent'); if(!myRecords.length){ el.innerHTML='
🎮

참가한 플레이 기록이 없습니다.

'; return; } // 내 통계 let myWins=0, myPlays=myRecords.length, myTotalScore=0; const myGameCount={}; myRecords.forEach(r=>{ const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); if(sorted[0]?.name===me) myWins++; const me_p = r.players.find(p=>p.name===me); if(me_p) myTotalScore+=Number(me_p.score||0); myGameCount[r.gameName]=(myGameCount[r.gameName]||0)+1; }); const myRate = myPlays ? Math.round((myWins/myPlays)*100) : 0; const myAvg = myPlays ? Math.round(myTotalScore/myPlays) : 0; const favGame = Object.entries(myGameCount).sort((a,b)=>b[1]-a[1])[0]; el.innerHTML = `
👤 ${me}의 기록
${myPlays}
총 플레이
${myWins}
승리
${myRate}%
승률
${myAvg}
평균점수
${favGame?`
🎲 최다 플레이: ${favGame[0]} (${favGame[1]}회)
`:''}
📅 참가 기록 (${myPlays}건)
${myRecords.map(r=>{ const sorted=[...r.players].sort((a,b)=>Number(b.score)-Number(a.score)); const winners=getWinners(r.players); const winner=winners[0]; const isWin=winner===me; const myScore=r.players.find(p=>p.name===me)?.score||'0'; return`
${r.date} · ${r.gameName}
${isWin?'🥇 승리':'패배'}
내 점수: ${myScore}점
${r.players.map(p=>`${p.name}: ${p.score}점${winners.includes(p.name)?' 👑':''}`).join('')}
${r.memo?`
${r.memo}
`:''}
`; }).join('')}`; } function renderPlayRecordList(){ const q = (document.getElementById('playSearchInput')?.value||'').toLowerCase().trim(); const targetGames = q ? games.filter(g=>g.name.toLowerCase().includes(q)) : games; const allRecords = []; targetGames.forEach(g=>(playRecords[g.id]||[]).forEach(r=>allRecords.push({...r, gameName:g.name}))); allRecords.sort((a,b)=>(b.date||'').localeCompare(a.date||'')); const el = document.getElementById('playRecordContent'); if(!allRecords.length){ el.innerHTML=`
🎮

${q?`"${q}" 기록 없음`:'기록이 없습니다.'}

`; return; } el.innerHTML = allRecords.slice(0,30).map(r=>{ const winners=getWinners(r.players); const winner=winners[0]; const canEdit = currentUser.isAdmin || r.recordedBy===currentUser.id; return`
${r.date} · ${r.gameName}
${canEdit?`
`:''}
${r.players.map(p=>`${p.name}: ${p.score}점${winners.includes(p.name)?' 👑':''}`).join('')}
${r.memo?`
${r.memo}
`:''}
`; }).join(''); } // ── 플레이 기록 입력 ── function openPlaySelectModalWithPlayers(matchingId){ // 매칭 참가자 목록 미리 수집 const joins = matchingJoins.filter(j=>j.matching_id==matchingId); const playerNames = joins.map(j=>j.member_name); // 게임 선택 모달 열기 - 선택 후 플레이 모달에 참가자 자동 채우기 window._pendingMatchingPlayers = playerNames; openPlaySelectModal(); } function openPlaySelectModal(){ document.getElementById('playSelectSearch').value = ''; renderPlaySelectList(); document.getElementById('playSelectModal').classList.add('open'); } function renderPlaySelectList(){ const q = document.getElementById('playSelectSearch').value.trim().toLowerCase(); const list = games.filter(g=> !q || g.name.toLowerCase().includes(q)); const el = document.getElementById('playSelectList'); if(!list.length){ el.innerHTML = '
게임이 없습니다
'; return; } el.innerHTML = list.map(g=>`
${g.name}
${g.genre||''} ${g.players||''}
🎮 ${(playRecords[g.id]||[]).length}회
`).join(''); } function openPlayModalWithPlayers(gameId){ currentPlayGameId = gameId ? String(gameId) : null; const g = gameId ? games.find(x=>String(x.id)===String(gameId)) : null; // 게임 select 채우기 fillGameOptions('pm_game_select'); const sel = document.getElementById('pm_game_select'); const custom = document.getElementById('pm_game_custom'); if(g){ sel.value = g.name; custom.value = ''; document.getElementById('playModalTitle').textContent = `🎮 ${g.name} 플레이 기록`; } else { sel.value = ''; custom.value = ''; document.getElementById('playModalTitle').textContent = '🎮 플레이 기록'; } document.getElementById('pm_date').value = today(); document.getElementById('pm_memo').value = ''; document.getElementById('pm_players_wrap').innerHTML = ''; const pending = window._pendingMatchingPlayers; if(pending && pending.length){ pending.forEach(name => addPlayerRow(name)); window._pendingMatchingPlayers = null; } else { addPlayerRow(currentUser.name); addPlayerRow(); } closeById('gameDetailModal'); closeById('rentModal'); document.getElementById('playModal').classList.add('open'); } function onPlayGameSelect(val){ // select에서 선택하면 직접입력 비우기 if(val) document.getElementById('pm_game_custom').value = ''; const g = games.find(x=>x.name===val); currentPlayGameId = g ? String(g.id) : null; document.getElementById('playModalTitle').textContent = val ? `🎮 ${val} 플레이 기록` : '🎮 플레이 기록'; } // openPlayModal = openPlayModalWithPlayers (하위호환) function openPlayModal(gameId){ openPlayModalWithPlayers(gameId); } function addPlayerRow(defaultName='', isWinner=false){ const wrap=document.getElementById('pm_players_wrap'); const div=document.createElement('div'); div.style.cssText='display:grid;grid-template-columns:1fr 1fr auto auto;gap:4px;margin-bottom:6px;align-items:center;'; div.innerHTML=` `; wrap.appendChild(div); } function toggleWinner(btn){ const isNow = btn.dataset.winner === '1'; btn.dataset.winner = isNow ? '0' : '1'; btn.style.background = isNow ? 'var(--card)' : 'rgba(251,191,36,.3)'; btn.style.borderColor = isNow ? 'var(--border)' : 'var(--yellow)'; btn.style.color = isNow ? 'var(--muted)' : 'var(--yellow)'; } async function savePlayRecord(){ const date=document.getElementById('pm_date').value; const memo=document.getElementById('pm_memo').value.trim(); const rows=document.querySelectorAll('#pm_players_wrap > div'); const players=[]; rows.forEach(row=>{ const inputs=row.querySelectorAll('input'); const winBtn=row.querySelector('button[data-winner]'); const name=inputs[0].value.trim(); const score=inputs[1].value.trim(); const winner=winBtn?.dataset.winner==='1'; if(name) players.push({name, score: score||'0', winner}); }); if(!players.length){showToast('참가자를 입력하세요','var(--red)');return;} // 게임명 결정: select > 직접입력 const gameSelVal = document.getElementById('pm_game_select').value; const gameCustomVal = document.getElementById('pm_game_custom').value.trim(); const finalGameName = gameSelVal || gameCustomVal; if(!finalGameName){showToast('게임 이름을 입력하세요','var(--red)');return;} // currentPlayGameId도 동기화 if(gameSelVal){ const gf = games.find(x=>x.name===gameSelVal); if(gf) currentPlayGameId = String(gf.id); } const gameId = currentPlayGameId || ('custom_'+Date.now()); const gameName = finalGameName; if(!isDemo()){ try{ const playerNames = players.map(p=>p.name); const scoreData = JSON.stringify(players); const memoFull = memo ? memo+'||scores:'+scoreData : '||scores:'+scoreData; await sb('play_records','POST',{ game_id: gameId, game_name: gameName, played_date: date||today(), players: playerNames, memo: memoFull, recorded_by: currentUser.id||null }); await fetchPlayRecords(); }catch(e){ showToast('저장 오류: '+e.message,'var(--red)'); console.error('플레이기록 오류', e); return; } } else { if(!playRecords[gameId]) playRecords[gameId]=[]; playRecords[gameId].unshift({date,players,memo}); localStorage.setItem('bgPlayRecords', JSON.stringify(playRecords)); } closeById('playModal'); showToast('🎮 기록 저장 완료!','var(--green)'); renderGames(); switchTab('playstats'); } // ── 게임 상세 ── function openDetail(id){ currentGameId=id; const g=games.find(x=>x.id===id); if(!g) return; const st=getStatus(g); const isMyRental=g.renterName===currentUser.name; const plays=(playRecords[g.id]||[]); let rentalHtml=''; if(g.status==='rented'){ const od=isOverdue(g); rentalHtml=`

대여 정보

대여자👤 ${g.renterName}${isMyRental?'':''}
${g.renterPhone?`
연락처${g.renterPhone}
`:''}
대여일${g.rentalStartDate||'-'}
반납예정${g.rentalEndDate||'-'}${g.rentalEndDate?(od?' ('+Math.abs(daysLeft(g.rentalEndDate))+'일 연체)':' (D-'+daysLeft(g.rentalEndDate)+')'):''}
`; } let recentPlay=''; if(plays.length){ const last=plays[0]; const sorted=[...last.players].sort((a,b)=>Number(b.score)-Number(a.score)); recentPlay=`

최근 플레이 (총 ${plays.length}회)

날짜${last.date}
우승🏆 ${getWinners(last.players).join(', ')||'-'}
`; } let actionHtml=''; if(st==='available') actionHtml=``; else if(isMyRental||currentUser.isAdmin) actionHtml=``; else actionHtml=``; actionHtml+=``; actionHtml+=`▶ 유튜브`; if(currentUser.isAdmin) actionHtml+=``; document.getElementById('detailContent').innerHTML=`
${g.name}
${st==='available'?'대여가능':st==='overdue'?'⚠연체':'대여중'}

게임 정보

${g.genre?`
장르${g.genre}
`:''} ${g.players?`
인원👥 ${g.players}
`:''} ${g.location?`
위치📦 ${g.location}
`:''} ${g.memo?`
메모${g.memo}
`:''}
${rentalHtml}${recentPlay}
${actionHtml}
`; document.getElementById('gameDetailModal').classList.add('open'); } // ── 대여 ── async function openYoutubeLink(gameName, e){ e.preventDefault(); try{ const res = await fetch(`${PUSH_WORKER}/youtube?q=${encodeURIComponent(gameName)}`); const data = await res.json(); if(data.items && data.items.length){ window.open(data.items[0].url, '_blank'); } else { // 검색 결과 없으면 유튜브 검색으로 window.open(`https://www.youtube.com/results?search_query=${encodeURIComponent(gameName+' 보드게임 룰 방법')}`, '_blank'); } }catch(err){ window.open(`https://www.youtube.com/results?search_query=${encodeURIComponent(gameName+' 보드게임 룰 방법')}`, '_blank'); } } function openRentModal(id){ currentGameId=id; const g=games.find(x=>x.id===id); document.getElementById('rentTitle').textContent=`📤 ${g.name} 대여 신청`; document.getElementById('rentUserName').textContent=`${currentUser.name} (${currentUser.region||''} ${currentUser.dept})`; const todayStr=today(); document.getElementById('rd_start').value=todayStr; document.getElementById('rd_end').value=''; // 반납 예정일 최대 7일 제한 const maxDate=new Date(); maxDate.setDate(maxDate.getDate()+7); const maxStr=maxDate.toISOString().split('T')[0]; document.getElementById('rd_end').max=maxStr; document.getElementById('rd_end').min=todayStr; document.getElementById('rd_memo').value=''; closeById('gameDetailModal'); document.getElementById('rentModal').classList.add('open'); } async function confirmRent(){ const rentalStartDate=document.getElementById('rd_start').value; const rentalEndDate=document.getElementById('rd_end').value; if(!rentalStartDate){ showToast('대여일을 입력해주세요.','var(--red)'); return; } if(!rentalEndDate){ showToast('반납예정일을 입력해주세요.','var(--red)'); return; } if(rentalEndDate < rentalStartDate){ showToast('반납예정일이 대여일보다 빠를 수 없어요.','var(--red)'); return; } const maxAllowed=new Date(rentalStartDate); maxAllowed.setDate(maxAllowed.getDate()+7); if(new Date(rentalEndDate) > maxAllowed){ showToast('반납 예정일은 대여일로부터 7일을 초과할 수 없어요.','var(--red)'); return; } const btn=document.getElementById('rentConfirmBtn'); btn.disabled=true; btn.textContent='처리중...'; const renterName=currentUser.name; try{ if(isDemo()){ const g=games.find(x=>x.id===currentGameId); Object.assign(g,{status:'rented',renterName,rentalStartDate,rentalEndDate}); } else { await sb('games','PATCH',{ status:'rented', renter_name:renterName, rental_start:rentalStartDate, rental_end_date:rentalEndDate },`?id=eq.${currentGameId}`); await sb('rental_history','POST',{ game_id:currentGameId, game_name:games.find(g=>g.id===currentGameId)?.name||'', member_id:currentUser.id||null, member_name:renterName, rental_date:rentalStartDate, due_date:rentalEndDate }); await fetchGames(); } renderGames(); closeById('rentModal'); showToast('✅ 대여 신청 완료!','var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } finally{ btn.disabled=false; btn.textContent='대여 신청'; } } function returnGame(id){ const g = games.find(x=>x.id===id); if(!g) return; currentGameId = id; document.getElementById('returnGameName').textContent = g.name; document.getElementById('return_location').value = ''; document.getElementById('return_memo').value = ''; closeById('gameDetailModal'); document.getElementById('returnModal').classList.add('open'); } async function confirmReturn(){ const location = document.getElementById('return_location').value.trim(); if(!location){ showToast('반납 장소를 입력하세요.','var(--red)'); return; } const btn = document.getElementById('returnConfirmBtn'); btn.disabled=true; btn.textContent='처리중...'; const id = currentGameId; try{ if(isDemo()){ const g=games.find(x=>x.id===id); g.status='available'; g.renterName=''; g.rentalStartDate=''; g.rentalEndDate=''; g.location=location; } else { await sb('games','PATCH',{status:'available', renter_name:null, rental_start:null, rental_end_date:null, location},`?id=eq.${id}`); await sb('rental_history','PATCH',{return_date:today()},`?game_id=eq.${id}&return_date=is.null`); await fetchGames(); } renderGames(); renderMyRentals(); renderAdmin(); closeById('returnModal'); showToast('✅ 반납 완료! 보관 장소가 업데이트됐어요.','var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } finally{ btn.disabled=false; btn.textContent='✅ 반납 완료'; } } async function adminReturn(id){ returnGame(id); } function openEditGameModal(id){ const g = games.find(x=>x.id===id); if(!g) return; currentGameId = id; document.getElementById('eg_name').value = g.name||''; document.getElementById('eg_genre').value = g.genre||''; document.getElementById('eg_players').value = g.players||''; document.getElementById('eg_location').value = g.location||''; document.getElementById('eg_memo').value = g.memo||''; closeById('gameDetailModal'); document.getElementById('editGameModal').classList.add('open'); } async function saveEditGame(){ const name = document.getElementById('eg_name').value.trim(); if(!name){ showToast('게임 이름을 입력하세요.','var(--red)'); return; } const payload = { name, genre: document.getElementById('eg_genre').value, players: document.getElementById('eg_players').value.trim(), location: document.getElementById('eg_location').value.trim(), memo: document.getElementById('eg_memo').value.trim() }; try{ if(isDemo()){ const g = games.find(x=>x.id===currentGameId); if(g) Object.assign(g, payload); } else { await sb('games','PATCH',payload,`?id=eq.${currentGameId}`); await fetchGames(); } renderGames(); closeById('editGameModal'); showToast('✅ 게임 정보 수정 완료!','var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } // ── 플레이 기록 수정/삭제 ── let _editPlayId = null; function openEditPlayModal(id){ // 전체 records에서 찾기 let rec = null; for(const gid of Object.keys(playRecords)){ const found = playRecords[gid].find(r=>r.id===id); if(found){ rec=found; break; } } if(!rec) return; _editPlayId = id; document.getElementById('ep_date').value = rec.date||''; document.getElementById('ep_memo').value = rec.memo||''; // 플레이어 행 채우기 const wrap = document.getElementById('ep_players_wrap'); wrap.innerHTML = ''; rec.players.forEach(p => addEpPlayerRow(p.name, p.score, p.winner)); document.getElementById('editPlayModal').classList.add('open'); } function addEpPlayerRow(name='', score='', winner=false){ const wrap = document.getElementById('ep_players_wrap'); const div = document.createElement('div'); div.style.cssText = 'display:grid;grid-template-columns:1fr 1fr auto auto;gap:6px;margin-bottom:8px;align-items:center;'; div.innerHTML = ` `; wrap.appendChild(div); } async function saveEditPlayRecord(){ const id = _editPlayId; const date = document.getElementById('ep_date').value; const memo = document.getElementById('ep_memo').value.trim(); const rows = document.querySelectorAll('#ep_players_wrap > div'); const players = []; rows.forEach(row=>{ const inputs = row.querySelectorAll('input'); const winBtn = row.querySelector('button[data-winner]'); const name = inputs[0].value.trim(); const score = inputs[1].value.trim(); const winner = winBtn?.dataset.winner==='1'; if(name) players.push({name, score:score||'0', winner}); }); if(!players.length){ showToast('참가자를 입력하세요','var(--red)'); return; } try{ const playerNames = players.map(p=>p.name); const memoFull = memo ? memo+'||scores:'+JSON.stringify(players) : '||scores:'+JSON.stringify(players); await sb('play_records','PATCH',{ played_date: date, players: playerNames, memo: memoFull }, `?id=eq.${id}`); await fetchPlayRecords(); closeById('editPlayModal'); renderPlayRecordList(); renderPlayStats(); showToast('✅ 기록 수정 완료!','var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } async function deletePlayRecord(id){ if(!confirm('이 플레이 기록을 삭제하시겠습니까?')) return; try{ await sb('play_records','DELETE',null,`?id=eq.${id}`); await fetchPlayRecords(); renderPlayRecordList(); renderPlayStats(); showToast('🗑 기록 삭제됨','var(--muted)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } // ── 게임 추가 ── function openAddModal(){ ['ag_name','ag_players','ag_location','ag_memo'].forEach(i=>document.getElementById(i).value=''); document.getElementById('ag_genre').value=''; document.getElementById('addModal').classList.add('open'); } async function addGame(){ const name=document.getElementById('ag_name').value.trim(); if(!name){showToast('게임 이름을 입력하세요.','var(--red)');return;} const payload={name, genre:document.getElementById('ag_genre').value, players:document.getElementById('ag_players').value.trim(), location:document.getElementById('ag_location').value.trim(), memo:document.getElementById('ag_memo').value.trim(), status:'available'}; try{ if(isDemo()) games.push({id:Date.now().toString(),...payload,renterName:'',rentalStartDate:'',rentalEndDate:''}); else{ await sb('games','POST',payload); await fetchGames(); } renderGames(); renderGameFilterChips(); closeById('addModal'); showToast('🎲 게임 추가 완료!','var(--green)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } async function deleteGame(id){ if(!confirm('게임을 삭제하시겠습니까?')) return; try{ if(isDemo()) games=games.filter(x=>x.id!==id); else{ await sb('games','DELETE',null,`?id=eq.${id}`); await fetchGames(); } renderGames(); renderGameFilterChips(); closeById('gameDetailModal'); showToast('🗑 삭제 완료','var(--muted)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } // ── 구매요청 ── let purchases = []; let currentPurchaseFilter = 'all'; async function fetchPurchases(){ try{ const data = await sb('purchase_requests','GET',null,'?order=created_at.desc'); purchases = data||[]; }catch(e){ console.error('구매요청 로드 오류',e); purchases=[]; } } function setPurchaseFilter(f, el){ currentPurchaseFilter = f; document.querySelectorAll('#tab-purchase .filter-row .chip').forEach(c=>c.classList.remove('active')); el.classList.add('active'); renderPurchaseList(); } async function renderPurchaseList(){ await fetchPurchases(); const el = document.getElementById('purchaseList'); if(!el) return; // 통계 렌더링 const statsEl = document.getElementById('purchaseStats'); if(statsEl){ // 필터 적용된 목록 const filteredList = currentPurchaseFilter==='all' ? purchases : purchases.filter(p=>p.status===currentPurchaseFilter); const filterLabel = currentPurchaseFilter==='all' ? '전체' : currentPurchaseFilter; const total = filteredList.length; // 가격 합산 const calcPrice = (arr) => arr .filter(p=>p.price) .reduce((sum,p)=>{ const n=parseInt((p.price||'').replace(/[^0-9]/g,'')); return sum+(isNaN(n)?0:n); },0); const filteredPrice = calcPrice(filteredList); const totalAllPrice = calcPrice(purchases); statsEl.innerHTML = `
${filterLabel} 요청
${total}건
${filterLabel} 금액
${filteredPrice>0?filteredPrice.toLocaleString()+'원':'-'}
${currentPurchaseFilter!=='all'?`
전체 금액
${totalAllPrice>0?totalAllPrice.toLocaleString()+'원':'-'}
`:''}`; } let list = currentPurchaseFilter==='all' ? purchases : purchases.filter(p=>p.status===currentPurchaseFilter); if(!list.length){ el.innerHTML='
🛒

구매요청이 없어요.

'; return; } const statusBadge = s => s==='승인' ? '✅ 승인' : s==='반려' ? '❌ 반려' : '🔍 검토중'; el.innerHTML = list.map(p=>`
${p.game_name} 🔗
${(p.requester_name===currentUser.name || currentUser.isAdmin) ? `` : ''} ${statusBadge(p.status||'검토중')}
${p.price?`
💰 ${p.price}
`:''} ${p.reason?`
${p.reason}
`:''}
👤 ${p.requester_name} · ${(p.created_at||'').slice(0,10)}
${currentUser.isAdmin ? ` ` : ''} ${(p.requester_name===currentUser.name || currentUser.isAdmin) ? ` ` : ''}
`).join(''); } async function searchNaverPrice(){ const name = document.getElementById('purchase_name').value.trim(); if(!name){ showToast('게임명을 먼저 입력하세요.','var(--red)'); return; } const resultEl = document.getElementById('naverPriceResult'); resultEl.style.display='block'; resultEl.innerHTML='
🔍 검색 중...
'; try{ const res = await fetch(`${PUSH_WORKER}/naver-price?q=${encodeURIComponent(name+' 보드게임')}`); const data = await res.json(); if(!data.items||!data.items.length){ resultEl.innerHTML='
검색 결과 없음
'; return; } const best = data.items[0]; document.getElementById('purchase_price').value = best.price.toLocaleString()+'원'; if(document.getElementById('purchase_link_hidden')) document.getElementById('purchase_link_hidden').value = best.link||''; resultEl.innerHTML = data.items.map((item,i)=>`
${item.title.slice(0,25)}${item.title.length>25?'…':''}
${item.mall}
${item.price.toLocaleString()}원
바로가기
`).join(''); }catch(e){ resultEl.innerHTML='
검색 실패: '+e.message+'
'; } } function openPurchaseModal(){ document.getElementById('purchase_edit_id').value = ''; document.getElementById('purchase_name').value = ''; document.getElementById('purchase_price').value = ''; document.getElementById('purchase_reason').value = ''; document.getElementById('naverPriceResult').style.display='none'; if(document.getElementById('purchase_link_hidden')) document.getElementById('purchase_link_hidden').value=''; document.getElementById('purchaseModalTitle').textContent = '🛒 구매 요청'; document.getElementById('purchaseModal').classList.add('open'); } async function submitPurchase(){ const name = document.getElementById('purchase_name').value.trim(); if(!name){ showToast('게임명을 입력하세요.','var(--red)'); return; } const reason = document.getElementById('purchase_reason').value.trim(); const price = document.getElementById('purchase_price').value.trim(); const editId = document.getElementById('purchase_edit_id').value; // 구매 링크: 검색 결과에서 첫번째 링크 저장 const purchaseLink = document.getElementById('purchase_link_hidden')?.value || null; try{ if(editId){ await sb('purchase_requests','PATCH',{ game_name: name, reason: reason||null, price: price||null, purchase_link: purchaseLink },`?id=eq.${editId}`); showToast('✅ 수정됐어요!','var(--green)'); } else { await sb('purchase_requests','POST',{ game_name: name, reason: reason||null, price: price||null, purchase_link: purchaseLink, requester_name: currentUser.name, status: '검토중', likes: [] }); showToast('✅ 요청이 등록됐어요!','var(--green)'); } closeById('purchaseModal'); renderPurchaseList(); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } function openEditPurchaseModal(id){ const p = purchases.find(x=>x.id===id); if(!p) return; document.getElementById('purchase_edit_id').value = id; document.getElementById('purchase_name').value = p.game_name||''; document.getElementById('purchase_price').value = p.price||''; document.getElementById('purchase_reason').value = p.reason||''; document.getElementById('naverPriceResult').style.display='none'; if(document.getElementById('purchase_link_hidden')) document.getElementById('purchase_link_hidden').value = p.purchase_link||''; document.getElementById('purchaseModalTitle').textContent = '✏️ 요청 수정'; document.getElementById('purchaseModal').classList.add('open'); } async function togglePurchaseLike(id){ const p = purchases.find(x=>x.id===id); if(!p) return; const likes = p.likes||[]; const newLikes = likes.includes(currentUser.name) ? likes.filter(n=>n!==currentUser.name) : [...likes, currentUser.name]; try{ await sb('purchase_requests','PATCH',{likes:newLikes},`?id=eq.${id}`); p.likes = newLikes; renderPurchaseList(); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } function onPurchaseStatusChange(id, sel){ const status = sel.value; if(status === '승인'){ // 선택 되돌리기 (모달 확인 후 변경) sel.value = purchases.find(x=>x.id===id)?.status || '검토중'; document.getElementById('approve_purchase_id').value = id; document.getElementById('approve_location').value = ''; document.getElementById('approveModal').classList.add('open'); } else { changePurchaseStatus(id, status); } } async function confirmApprove(){ const id = document.getElementById('approve_purchase_id').value; const location = document.getElementById('approve_location').value.trim(); if(!location){ showToast('보관 장소를 입력하세요.','var(--red)'); return; } closeById('approveModal'); await changePurchaseStatus(id, '승인', location); } async function changePurchaseStatus(id, status, location=''){ try{ await sb('purchase_requests','PATCH',{status},`?id=eq.${id}`); const p = purchases.find(x=>x.id===id); if(p) p.status = status; // 승인 시 게임 목록에 자동 추가 (이미 있는 게임 제외) if(status === '승인' && p){ const exists = games.find(g => g.name === p.game_name); if(!exists){ try{ const res = await sb('games','POST',{ name: p.game_name, genre: '', players: '', location: '', memo: '', status: 'available' }); await fetchGames(); showToast(`✅ "${p.game_name}" 게임 목록에 추가됐어요!`,'var(--green)'); }catch(e2){ console.error('게임 목록 추가 오류',e2); } } else { showToast(`ℹ️ "${p.game_name}"은 이미 게임 목록에 있어요.`,'var(--muted)'); } } renderPurchaseList(); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } async function deletePurchase(id){ if(!confirm('요청을 삭제할까요?')) return; try{ await sb('purchase_requests','DELETE',null,`?id=eq.${id}`); purchases = purchases.filter(x=>x.id!==id); renderPurchaseList(); showToast('🗑 삭제됐어요.','var(--muted)'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } // ── 엑셀 업로드 ── function handleDrop(e){ e.preventDefault(); document.getElementById('uploadZone').classList.remove('drag'); if(e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); } function handleFile(file){ if(!file) return; if(!file.name.match(/\.xlsx?$/i)){showToast('xlsx 파일만 가능','var(--red)');return;} const reader=new FileReader(); reader.onload=function(e){ try{ const wb=XLSX.read(e.target.result,{type:'array'}); const sheetName=wb.SheetNames.find(n=>n.includes('게임목록')||n.includes('게임'))||wb.SheetNames[0]; const ws=wb.Sheets[sheetName]; const raw=XLSX.utils.sheet_to_json(ws,{header:1,defval:''}); let headerRow=-1; for(let i=0;iString(c)).some(c=>c.includes('게임')&&(c.includes('이름')||c.includes('명')))){headerRow=i;break;} } if(headerRow<0){showToast('헤더를 찾을 수 없음','var(--red)');return;} const headers=raw[headerRow].map(c=>String(c).trim()); const ni=headers.findIndex(h=>h.includes('이름')||h.includes('명')); const gi=headers.findIndex(h=>h.includes('장르')); const mni=headers.findIndex(h=>h.includes('최소')); const mxi=headers.findIndex(h=>h.includes('최대')); const pi=headers.findIndex(h=>h.includes('인원')&&!h.includes('최소')&&!h.includes('최대')); const li=headers.findIndex(h=>h.includes('위치')||h.includes('보관')); const mei=headers.findIndex(h=>h.includes('메모')||h.includes('부품')); const existing=new Set(games.map(g=>g.name.trim())); const parsed=[]; for(let i=headerRow+1;i=0&&mxi>=0){const mn=String(row[mni]||'').trim(),mx=String(row[mxi]||'').trim();if(mn&&mx)players=mn+'~'+mx+'명';} else if(pi>=0) players=String(row[pi]||'').trim(); parsed.push({name,genre:gi>=0?String(row[gi]||'').trim():'',players,location:li>=0?String(row[li]||'').trim():'',memo:mei>=0?String(row[mei]||'').trim():'',isDuplicate:existing.has(name)}); } if(!parsed.length){showToast('데이터가 없음','var(--yellow)');return;} pendingUpload=parsed; showUploadPreview(parsed); }catch(err){showToast('파일 오류: '+err.message,'var(--red)');} }; reader.readAsArrayBuffer(file); } function showUploadPreview(data){ const nw=data.filter(d=>!d.isDuplicate), dup=data.filter(d=>d.isDuplicate); let html=`
📊 신규 ${nw.length}개 / 중복 ${dup.length}개
${data.map(d=>`
${d.isDuplicate?'⊘ ':''}${d.name} ${d.genre||'-'} ${d.players?'· '+d.players:''}
`).join('')}
`; const el=document.getElementById('uploadResult'); el.innerHTML=html; el.style.display='block'; const btn=document.getElementById('uploadConfirmBtn'); btn.style.display=nw.length?'block':'none'; btn.textContent=`✅ 신규 ${nw.length}개 등록하기`; } async function confirmUpload(){ const nw=pendingUpload.filter(d=>!d.isDuplicate); if(!nw.length){showToast('신규 게임 없음','var(--yellow)');return;} const btn=document.getElementById('uploadConfirmBtn'); btn.disabled=true; btn.textContent='등록 중...'; try{ if(isDemo()){ nw.forEach(item=>{ games.push({id:Date.now().toString()+Math.random(),...item,status:'available',renterName:'',rentalStartDate:'',rentalEndDate:''}); }); } else { const payload = nw.map(item=>({name:item.name,genre:item.genre,players:item.players,location:item.location,memo:item.memo,status:'available'})); await sb('games','POST',payload); await fetchGames(); } renderGames(); renderGameFilterChips(); showToast(`🎲 ${nw.length}개 등록 완료!`,'var(--green)'); document.getElementById('uploadResult').style.display='none'; btn.style.display='none'; pendingUpload=[]; switchTab('games'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } finally{ btn.disabled=false; btn.textContent='등록하기'; } } // ════════════════════════════════════ // ── 플레이 기록 일괄 업로드 ── let pendingPlayUpload = []; function handlePlayDrop(e){ e.preventDefault(); document.getElementById('playUploadZone').classList.remove('drag'); const file = e.dataTransfer.files[0]; if(file) handlePlayFile(file); } function handlePlayFile(file){ if(!file) return; if(!file.name.match(/\.xlsx?$/i)){ showToast('xlsx 파일만 가능','var(--red)'); return; } const reader = new FileReader(); reader.onload = function(e){ try{ const wb = XLSX.read(e.target.result, {type:'array'}); // '플레이기록' 시트 찾기 const sheetName = wb.SheetNames.find(n=>n.includes('플레이'))||wb.SheetNames[0]; const ws = wb.Sheets[sheetName]; const raw = XLSX.utils.sheet_to_json(ws, {header:1, defval:''}); // 헤더 행 찾기 (날짜 + 게임명 포함 행) let headerRow = -1; for(let i=0; iString(c)); if(row.some(c=>c.includes('날짜')) && row.some(c=>c.includes('게임'))){ headerRow = i; break; } } if(headerRow < 0){ showToast('헤더를 찾을 수 없어요. 서식 파일을 사용해주세요.','var(--red)'); return; } const headers = raw[headerRow].map(c=>String(c).trim()); const dateIdx = headers.findIndex(h=>h.includes('날짜')); const gameIdx = headers.findIndex(h=>h.includes('게임')); const memoIdx = headers.findIndex(h=>h.includes('메모')); // 플레이어/점수 컬럼 인덱스 (플레이어1~6, 점수1~6) const playerCols = [], scoreCols = []; headers.forEach((h,i)=>{ if(/플레이어\d/.test(h)) playerCols.push(i); if(/점수\d/.test(h)) scoreCols.push(i); }); const today = new Date().toISOString().split('T')[0]; const parsed = []; for(let i=headerRow+1; i= 0 ? String(row[memoIdx]||'').trim() : ''; // 플레이어 + 점수 파싱 const players = []; playerCols.forEach((pc, idx) => { const name = String(row[pc]||'').trim(); if(!name) return; const score = scoreCols[idx] !== undefined ? String(row[scoreCols[idx]]||'').trim() : ''; players.push({ name, score: score || '0' }); }); if(players.length === 0) continue; // 게임 ID 매칭 const matchedGame = games.find(g=>g.name.trim() === gameName.trim()); parsed.push({ date, gameName, gameId: matchedGame?.id || null, players, memo, matched: !!matchedGame }); } if(!parsed.length){ showToast('데이터가 없어요.','var(--yellow)'); return; } pendingPlayUpload = parsed; showPlayUploadPreview(parsed); }catch(err){ showToast('파일 오류: '+err.message,'var(--red)'); } }; reader.readAsArrayBuffer(file); } function showPlayUploadPreview(data){ const matched = data.filter(d=>d.matched); const unmatched = data.filter(d=>!d.matched); const html = `
📊 총 ${data.length}건  ·  게임 매칭 ${matched.length}건 ${unmatched.length ? ` · 미매칭 ${unmatched.length}건` : ''}
${data.map(d=>`
${d.matched?'🎲':'⚠️'} ${d.gameName} ${d.date}
${d.players.map(p=>`${p.name}(${p.score}점)`).join(' · ')}
${!d.matched?`
⚠️ 게임 목록에 없는 게임명 (그래도 등록됨)
`:''}
`).join('')}
`; const el = document.getElementById('playUploadResult'); el.innerHTML = html; el.style.display = 'block'; const btn = document.getElementById('playUploadConfirmBtn'); btn.style.display = 'block'; btn.textContent = `✅ 플레이 기록 ${data.length}건 등록하기`; } async function confirmPlayUpload(){ if(!pendingPlayUpload.length){ showToast('업로드할 데이터 없음','var(--yellow)'); return; } const btn = document.getElementById('playUploadConfirmBtn'); btn.disabled = true; btn.textContent = '등록 중...'; try{ let ok = 0; for(const rec of pendingPlayUpload){ const payload = { game_id: rec.gameId || null, game_name: rec.gameName, played_date: rec.date, players: rec.players.map(p=>p.name), memo: rec.memo + (rec.players.some(p=>p.score!=='0') ? '||scores:'+JSON.stringify(rec.players) : ''), recorded_by: currentUser.name }; await sb('play_records','POST', payload); ok++; } await fetchPlayRecords(); showToast(`🎲 ${ok}건 등록 완료!`,'var(--green)'); document.getElementById('playUploadResult').style.display='none'; btn.style.display='none'; pendingPlayUpload=[]; switchTab('playstats'); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } finally{ btn.disabled=false; btn.textContent='등록하기'; } } function downloadGameTemplate(){ const wb = XLSX.utils.book_new(); const rows = [ ['전남미플 게임목록 등록 서식','','','','',''], ['※ 1~3행 수정 금지 · 4행부터 입력 · 중복 게임명 건너뜀','','','','',''], ['게임명','장르','인원','보관장소','메모',''], ['예) 카탄','전략','3~6명','11층','설명서 포함',''], ['예) 루미큐브','숫자','2~4명','서부청사','',''], ]; const ws = XLSX.utils.aoa_to_sheet(rows); ws['!cols'] = [{wch:20},{wch:12},{wch:10},{wch:12},{wch:20},{wch:5}]; ws['!merges'] = [{s:{r:0,c:0},e:{r:0,c:5}},{s:{r:1,c:0},e:{r:1,c:5}}]; XLSX.utils.book_append_sheet(wb, ws, '게임목록'); XLSX.writeFile(wb, '전남미플_게임목록_서식.xlsx'); showToast('📥 게임목록 서식 다운로드 완료!', 'var(--green)'); } function downloadPlayTemplate(){ const wb = XLSX.utils.book_new(); // ── 플레이기록 시트 ── const rows = [ // 1행: 제목 ['전남미플 플레이 기록 업로드 서식','','','','','','','','','','','','','',''], // 2행: 안내 ['※ 파란 셀에 데이터를 입력하세요. 플레이어는 최대 6명까지 입력 가능합니다. 점수가 없는 게임은 점수 칸을 비워두세요.'], // 3행: 빈행 [], // 4행: 헤더 ['날짜(YYYY-MM-DD)','게임명(필수)','플레이어1(필수)','점수1','플레이어2','점수2','플레이어3','점수3','플레이어4','점수4','플레이어5','점수5','플레이어6','점수6','메모(선택)'], // 5행: 예시 [new Date().toISOString().slice(0,10), games[0]?.name||'아줄', '홍길동',120,'김철수',95,'이영희',80,'','','','','','','첫 번째 게임!'], ]; // 빈 입력 행 30개 for(let i=0;i<30;i++) rows.push(Array(15).fill('')); const ws = XLSX.utils.aoa_to_sheet(rows); ws['!cols'] = [18,22,14,8,14,8,14,8,14,8,14,8,14,8,25].map(w=>({wch:w})); ws['!merges'] = [{s:{r:0,c:0},e:{r:0,c:14}},{s:{r:1,c:0},e:{r:1,c:14}}]; XLSX.utils.book_append_sheet(wb, ws, '플레이기록'); // ── 작성안내 시트 ── const guide = [ ['항목','설명'], ['날짜','YYYY-MM-DD 형식 (예: 2025-03-15). 비우면 오늘 날짜로 처리됨'], ['게임명','필수 입력. 게임 목록에 없는 이름도 등록 가능 (자동 매칭)'], ['플레이어1~6','이름 입력. 플레이어1은 필수, 나머지는 선택'], ['점수1~6','숫자만 입력. 점수 없는 게임은 비워두기'], ['메모','자유 입력 (예: 확장팩 사용, 룰 변형 등)'], ['',''], ['주의사항','헤더 행(1~4행)은 수정하지 마세요'], ['','플레이어 이름은 앱에 등록된 이름과 정확히 일치해야 합니다'], ['','한 행이 하나의 플레이 세션입니다'], ['','같은 날 여러 게임을 한 경우 여러 행에 입력하세요'], ['',''], ['현재 게임 목록',''], ...games.map(g=>[g.name, g.genre||'-']) ]; const ws2 = XLSX.utils.aoa_to_sheet(guide); ws2['!cols'] = [{wch:15},{wch:55}]; XLSX.utils.book_append_sheet(wb, ws2, '작성안내'); XLSX.writeFile(wb, '전남미플_플레이기록_서식.xlsx'); showToast('📥 서식 다운로드 완료!', 'var(--green)'); } // ════════════════════════════════════ // ── 공지사항 기능 ── let notices = []; let currentNoticeFilter = 'all'; async function fetchNotices(){ if(isDemo()){ notices = JSON.parse(localStorage.getItem('bgNotices')||'[]'); return; } try{ const data = await sb('notices','GET',null,'?order=created_at.desc'); notices = (data||[]).map(n=>({...n})); }catch(e){ console.error('공지 로드 오류',e); } } function setNoticeFilter(f, el){ currentNoticeFilter = f; document.querySelectorAll('#nf-all,#nf-notice,#nf-vote,#nf-open,#nf-closed').forEach(c=>c.classList.remove('active')); el.classList.add('active'); renderNoticeList(); } function renderNoticeList(){ // 공지 + 투표 합쳐서 시간순 정렬 let items = [ ...notices.map(n=>({...n, _type:'notice'})), ...votes.map(v=>({...v, _type:'vote'})) ].sort((a,b)=>(b.created_at||b.createdAt||'').localeCompare(a.created_at||a.createdAt||'')); if(currentNoticeFilter==='notice') items = items.filter(i=>i._type==='notice'); if(currentNoticeFilter==='vote') items = items.filter(i=>i._type==='vote'); if(currentNoticeFilter==='open') items = items.filter(i=>i._type==='vote' && !i.closed && !(i.deadline&&i.deadlinei._type==='vote' && (i.closed||(i.deadline&&i.deadline{ if(item._type==='notice'){ return `
📝 ${item.title}
공지
👤 ${item.author_name||'-'} 📅 ${(item.created_at||'').slice(0,10)}
${item.content||''}
`; } else { // 투표 카드 (기존 로직) const v = item; const total = v.options.reduce((s,o)=>s+o.voters.length, 0); const maxVotes = Math.max(...v.options.map(o=>o.voters.length), 1); const myVotedOpts = v.options.filter(o=>o.voters.includes(currentUser.name)); const hasVoted = myVotedOpts.length > 0; const isExpired = v.deadline && new Date(v.deadline) < new Date(); const isClosed = v.closed || isExpired; const bars = v.options.map(o=>{ const pct = total>0 ? Math.round(o.voters.length/total*100) : 0; const isTop = o.voters.length===maxVotes && o.voters.length>0; const isMine = myVotedOpts.some(m=>m.id===o.id); return `
${isMine?'✅ ':''}${o.label}
${o.voters.length}
`; }).join(''); return `
🗳 ${v.title}
${isClosed?'종료':hasVoted?'✅투표함':'투표하기'}
👤 ${v.creatorName} 📅 ${v.createdAt} ${v.deadline?`⏰ ~${fmtDeadline(v.deadline)}`:''} 🗳 ${total}표
${bars}
`; } }).join(''); } function openWriteNoticeModal(){ document.getElementById('wn_title').value=''; document.getElementById('wn_content').value=''; document.getElementById('writeNoticeModal').classList.add('open'); } async function saveNotice(){ const title = document.getElementById('wn_title').value.trim(); const content = document.getElementById('wn_content').value.trim(); if(!title){ showToast('제목을 입력하세요','var(--red)'); return; } if(!content){ showToast('내용을 입력하세요','var(--red)'); return; } const notice = { title, content, author_id: currentUser.id, author_name: currentUser.name, created_at: new Date().toISOString() }; if(isDemo()){ notice.id = Date.now().toString(); notices.unshift(notice); localStorage.setItem('bgNotices', JSON.stringify(notices)); } else { try{ const res = await sb('notices','POST', notice); if(res&&res[0]) notices.unshift(res[0]); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } closeById('writeNoticeModal'); renderNoticeList(); showToast('✅ 공지가 등록되었습니다','var(--green)'); // 전체 회원에게 푸시 알림 if(!isDemo()){ sendPushNotification(`📢 새 공지: ${title}`, content.length>60?content.slice(0,60)+'…':content, 'all'); } } async function openNoticeDetail(id){ const n = notices.find(x=>x.id==id); if(!n) return; const canDelete = currentUser.isAdmin || n.author_id===currentUser.id; // 댓글 로드 const commentData = await fetchComments('notice', id); const liked = false; document.getElementById('noticeDetailContent').innerHTML = `
${canDelete?``:''}
👤 ${n.author_name||'-'} · 📅 ${(n.created_at||'').slice(0,10)}
${n.content||''}
💬 댓글 ${commentData.length}
${renderComments(commentData,'notice',id)}
`; document.getElementById('noticeDetailModal').classList.add('open'); } async function deleteNotice(id){ if(!confirm('공지를 삭제할까요?')) return; if(isDemo()){ notices = notices.filter(n=>n.id!=id); localStorage.setItem('bgNotices', JSON.stringify(notices)); } else { try{ await sb('notices','DELETE',null,`?id=eq.${id}`); notices = notices.filter(n=>n.id!=id); } catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } closeById('noticeDetailModal'); renderNoticeList(); showToast('🗑 공지가 삭제되었습니다','var(--yellow)'); } // ── 게임 매칭 기능 ── let matchings = []; // [{id, week_label, meal_type, game_name, max_players, memo, joins:[]}] let matchingJoins = []; // [{id, matching_id, member_name, memo}] async function fetchMatchings(){ if(isDemo()){ matchings = JSON.parse(localStorage.getItem('bgMatchings')||'[]'); matchingJoins = JSON.parse(localStorage.getItem('bgMatchingJoins')||'[]'); await autoCreateWeeklyMatchings(); return; } try{ const [mData, jData] = await Promise.all([ sb('matchings','GET',null,'?order=match_date.asc'), sb('matching_joins','GET',null,'?order=created_at.asc') ]); // match_date를 YYYY-MM-DD 문자열로 정규화 matchings = (mData||[]).map(m=>({ ...m, match_date: m.match_date ? m.match_date.slice(0,10) : '' })); matchingJoins = jData||[]; await autoCreateWeeklyMatchings(); }catch(e){ console.error('매칭 로드 오류',e); } } // 자동 주간 생성: 오늘~+6일 중 match_date 없는 날 자동 생성 async function autoCreateWeeklyMatchings(){ const t = todayStr(); const existing = new Set(matchings.filter(m=>m.match_date>=t).map(m=>m.match_date+'-'+m.meal_type)); const toCreate = []; for(let i=0;i<7;i++){ const dateStr = addDays(t, i); if(!existing.has(dateStr+'-lunch')) toCreate.push({week_label:formatDateKo(dateStr), match_date:dateStr, meal_type:'lunch', game_name:'', max_players:6, memo:''}); if(!existing.has(dateStr+'-dinner')) toCreate.push({week_label:formatDateKo(dateStr), match_date:dateStr, meal_type:'dinner', game_name:'', max_players:6, memo:''}); } if(!toCreate.length) return; if(isDemo()){ toCreate.forEach(m=>{ m.id='auto-'+Date.now()+Math.random(); matchings.push(m); }); localStorage.setItem('bgMatchings', JSON.stringify(matchings)); } else { for(const m of toCreate){ try{ const res = await sb('matchings','POST',m); if(res&&res[0]) matchings.push({...res[0], match_date:(res[0].match_date||'').slice(0,10)}); }catch(e){ console.warn('자동 생성 오류:', e.message); // match_date 컬럼 미생성 등 DB 오류 안내 if(e.message&&(e.message.includes('match_date')||e.message.includes('column'))){ showToast('⚠ Supabase에 match_date 컬럼이 없습니다. SQL을 실행해주세요.','var(--red)'); return; } } } } } // ── 게임 매칭 헬퍼 ── function todayStr(){ const d = new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } function addDays(dateStr, n){ const d = new Date(dateStr+'T00:00:00'); d.setDate(d.getDate()+n); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } function formatDateKo(dateStr){ const d = new Date(dateStr+'T00:00:00'); const days=['일','월','화','수','목','금','토']; return (d.getMonth()+1)+'월 '+d.getDate()+'일('+days[d.getDay()]+')'; } function formatDateFull(dateStr){ const d = new Date(dateStr+'T00:00:00'); const days=['일요일','월요일','화요일','수요일','목요일','금요일','토요일']; const colors=['var(--red)','var(--text)','var(--text)','var(--text)','var(--text)','var(--text)','var(--blue)']; return { month:d.getMonth()+1, day:d.getDate(), dow:days[d.getDay()], color:colors[d.getDay()] }; } function fillGameOptions(selectId){ const el = document.getElementById(selectId); if(!el) return; const opts = games.map(g=>'').join(''); el.innerHTML = ''+opts; } function getJoins(matchingId){ return matchingJoins.filter(j=>j.matching_id===matchingId); } function renderMatchingList(){ const el = document.getElementById('matchingList'); const t = todayStr(); const weekDates = Array.from({length:7},(_,i)=>addDays(t,i)); const byDate = {}; weekDates.forEach(d=>{ byDate[d]=[]; }); matchings .filter(m=>m.match_date && weekDates.includes(m.match_date)) .sort((a,b)=>a.meal_type==='lunch'?-1:1) .forEach(m=>{ if(byDate[m.match_date]) byDate[m.match_date].push(m); }); el.innerHTML = weekDates.map(date=>{ const isToday = date===t; const isTomorrow = date===addDays(t,1); const items = byDate[date]; const fd = formatDateFull(date); const dateLabel = isToday?'오늘':isTomorrow?'내일':''; // ── 날짜 헤더 ── const header = `
${fd.day}
${fd.dow}
${fd.month}월
${dateLabel?`
${dateLabel}
`:''}
${isToday?'
TODAY
':''}
`; // ── 점심·저녁 2컬럼 ── const mealCards = ['lunch','dinner'].map(mealType=>{ const m = items.find(x=>x.meal_type===mealType); const mealEmoji = mealType==='lunch'?'🌞':'🌙'; const mealLabel = mealType==='lunch'?'점심':'저녁'; if(!m) return `
${mealEmoji}
${mealLabel}
`; const joins = getJoins(m.id); const myJoin = joins.find(j=>j.member_name===currentUser.name); const isClosed = !!m.is_closed; const avatars = joins.slice(0,3).map(j=> `
${j.member_name.charAt(0)}
` ).join(''); const more = joins.length>3?`
+${joins.length-3}
`:''; return `
${mealEmoji} ${mealLabel} ${isClosed ?'🔒마감' :myJoin?`✓신청 · 👥${joins.length}명` :`👥${joins.length}명`}
🎲 ${m.game_name||'미정'}
${(()=>{ // 신청자들이 원하는 게임 취합 (중복 제거) const allGames = new Set(); joins.forEach(j=>{ if(j.game_name) j.game_name.split(',').forEach(g=>{ const t=g.trim(); if(t) allGames.add(t); }); }); return allGames.size ? `
${[...allGames].map(g=>`🎲 ${g}`).join('')}
` : ''; })()} ${m.match_time||m.location?`
${m.match_time?`🕐 ${m.match_time}`:''} ${m.location?`📍 ${m.location}`:''}
`:''} ${m.memo?`
💬 ${m.memo}
`:''}
${joins.length?avatars+more:'첫 신청자!'}
${!myJoin&&!isClosed?``:''} ${myJoin&&!isClosed?``:''} ${isClosed&&!currentUser.isAdmin?'
신청이 마감되었습니다
':''} ${currentUser.isAdmin?`
`:''}
`; }).join(''); // ── 신청자 상세 목록 (접기/펼치기) ── const allJoins = ['lunch','dinner'].flatMap(mt=>{ const m = items.find(x=>x.meal_type===mt); if(!m||!getJoins(m.id).length) return []; return getJoins(m.id).map(j=>({...j, mealLabel:mt==='lunch'?'🌞 점심':'🌙 저녁', matchingId:m.id})); }); const totalCount = allJoins.length; const detailId = 'detail_'+date.replace(/-/g,''); const detailPanel = totalCount ? ` ` : ''; const toggleBtn = totalCount ? ` ` : `
아직 신청자가 없어요
`; return `
${header}
${mealCards}
${toggleBtn}${detailPanel}
`; }).join(''); } function toggleMatchingDetail(id, btn){ const el = document.getElementById(id); if(!el) return; const isOpen = el.style.display !== 'none'; el.style.display = isOpen ? 'none' : 'block'; const countMatch = btn.textContent.match(/\d+/); const count = countMatch ? countMatch[0] : ''; btn.innerHTML = isOpen ? `👥 신청현황 ${count}명 보기` : `👥 신청현황 ${count}명 닫기`; } // ── 주차 생성 (관리자) ── function openCreateMatchingModal(){ fillGameOptions('cm_game_lunch'); fillGameOptions('cm_game_dinner'); document.getElementById('cm_start_date').value = todayStr(); document.getElementById('cm_max').value = 6; document.getElementById('cm_memo').value = ''; document.getElementById('cm_game_lunch_custom').value = ''; document.getElementById('cm_game_dinner_custom').value = ''; document.getElementById('createMatchingModal').classList.add('open'); } async function createMatchingWeek(){ const startDate = document.getElementById('cm_start_date').value; if(!startDate){ showToast('시작 날짜를 선택하세요','var(--red)'); return; } const maxPlayers = parseInt(document.getElementById('cm_max').value)||6; const memo = document.getElementById('cm_memo').value.trim(); const lunchSel = document.getElementById('cm_game_lunch').value; const lunchCustom = document.getElementById('cm_game_lunch_custom').value.trim(); const lunchGame = lunchSel || lunchCustom || ''; const dinnerSel = document.getElementById('cm_game_dinner').value; const dinnerCustom = document.getElementById('cm_game_dinner_custom').value.trim(); const dinnerGame = dinnerSel || dinnerCustom || ''; const toCreate = []; for(let i=0;i<7;i++){ const dateStr = addDays(startDate, i); const weekLabel = formatDateKo(dateStr); toCreate.push({week_label:weekLabel, match_date:dateStr, meal_type:'lunch', game_name:lunchGame, max_players:maxPlayers, memo}); toCreate.push({week_label:weekLabel, match_date:dateStr, meal_type:'dinner', game_name:dinnerGame, max_players:maxPlayers, memo}); } if(isDemo()){ toCreate.forEach(m=>{ m.id=Date.now().toString()+Math.random(); matchings.push(m); }); localStorage.setItem('bgMatchings', JSON.stringify(matchings)); } else { try{ for(const m of toCreate){ const res = await sb('matchings','POST',m); if(res&&res[0]) matchings.push(res[0]); } }catch(e){ showToast('생성 오류: '+e.message,'var(--red)'); return; } } closeById('createMatchingModal'); renderMatchingList(); showToast('✅ 7일치 매칭 생성 완료!','var(--green)'); } // ── 신청 ── let jmSelectedGames = []; // 선택된 게임 목록 function openJoinModal(matchingId, mealLabel){ document.getElementById('joinMatchingId').value = matchingId; document.getElementById('joinMatchingTitle').textContent = '🎮 '+mealLabel+' 매칭 신청'; document.getElementById('jm_memo').value = ''; document.getElementById('jm_game_search').value = ''; document.getElementById('jm_dropdown').style.display = 'none'; jmSelectedGames = []; renderJmTags(); document.getElementById('joinMatchingModal').classList.add('open'); } function showJmAllGames(){ const dd = document.getElementById('jm_dropdown'); const filtered = games.filter(g => !jmSelectedGames.includes(g.name)); if(!filtered.length){ dd.style.display='none'; return; } dd.innerHTML = filtered.slice(0,10).map(g=>`
🎲 ${g.name}
`).join(''); dd.style.display = 'block'; } function renderJmDropdown(){ const q = document.getElementById('jm_game_search').value.trim().toLowerCase(); const dd = document.getElementById('jm_dropdown'); const filtered = games.filter(g=> (!q || g.name.toLowerCase().includes(q)) && !jmSelectedGames.includes(g.name)); if(!filtered.length){ dd.innerHTML = q ? `
검색 결과 없음 — 추가 버튼으로 직접 입력
` : ''; dd.style.display = q ? 'block' : 'none'; return; } const items = filtered.slice(0,8).map(g=>`
🎲 ${g.name}
`).join(''); dd.innerHTML = items || `
검색 결과 없음 — 추가 버튼으로 직접 입력
`; dd.style.display = 'block'; } function selectJmGame(name){ if(!jmSelectedGames.includes(name)) jmSelectedGames.push(name); document.getElementById('jm_game_search').value = ''; document.getElementById('jm_dropdown').style.display = 'none'; renderJmTags(); } function addJmGame(){ const val = document.getElementById('jm_game_search').value.trim(); if(!val) return; if(!jmSelectedGames.includes(val)) jmSelectedGames.push(val); document.getElementById('jm_game_search').value = ''; document.getElementById('jm_dropdown').style.display = 'none'; renderJmTags(); } function removeJmGame(name){ jmSelectedGames = jmSelectedGames.filter(g=>g!==name); renderJmTags(); } function renderJmTags(){ const wrap = document.getElementById('jm_selected_tags'); wrap.innerHTML = jmSelectedGames.map(name=>` 🎲 ${name} `).join(''); } // 드롭다운 외부 클릭 시 닫기 document.addEventListener('click', e=>{ if(!e.target.closest('#joinMatchingModal')) return; if(!e.target.closest('#jm_game_search') && !e.target.closest('#jm_dropdown')){ const dd = document.getElementById('jm_dropdown'); if(dd) dd.style.display='none'; } }); // CSS 제거 (체크박스 sel 스타일 더 이상 불필요) (function(){ const s = document.createElement('style'); s.textContent = '#jm_game_checks label.sel{background:var(--accent);color:#fff;border-color:var(--accent);}#jm_game_checks label.sel span{filter:brightness(10);}'; document.head.appendChild(s); })(); async function submitJoinMatching(){ const matchingId = document.getElementById('joinMatchingId').value; if(!matchingId || matchingId.startsWith('auto-')){ showToast('⚠ 매칭이 아직 DB에 저장되지 않았습니다. 새로고침 후 시도해주세요.','var(--red)'); return; } const memo = document.getElementById('jm_memo').value.trim(); const gameName = jmSelectedGames.join(', '); const join = { matching_id:matchingId, member_id:currentUser.id, member_name:currentUser.name, game_name:gameName, memo }; if(isDemo()){ join.id = Date.now().toString(); matchingJoins.push(join); localStorage.setItem('bgMatchingJoins', JSON.stringify(matchingJoins)); } else { try{ const res = await sb('matching_joins','POST',join); if(res&&res[0]) matchingJoins.push(res[0]); }catch(e){ if(e.message&&e.message.includes('duplicate')){ showToast('이미 신청하셨습니다','var(--yellow)'); return; } showToast('오류: '+e.message,'var(--red)'); return; } } closeById('joinMatchingModal'); renderMatchingList(); showToast('✅ 신청 완료!','var(--green)'); // 매칭 신청 시 참가자 전원에게 알림 if(!isDemo()){ const joins = matchingJoins.filter(j=>j.matching_id==matchingId); if(joins.length >= 2){ const matching = matchings.find(m=>m.id==matchingId); const dateLabel = matching ? `${matching.match_date} ${matching.meal_type||''}` : ''; const names = joins.map(j=>j.member_name); try{ await fetch(`${PUSH_WORKER}/send`,{ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ title: '🎮 전남미플 매칭', body: `${dateLabel} 현재 ${joins.length}명 참가 중 (${names.join(', ')})`, target: names }) }); }catch(e){ console.error('매칭 알림 오류',e); } } } } async function cancelJoinMatching(matchingId, joinId){ if(!confirm('신청을 취소할까요?')) return; if(isDemo()){ matchingJoins = matchingJoins.filter(j=>j.id!=joinId); localStorage.setItem('bgMatchingJoins', JSON.stringify(matchingJoins)); } else { try{ await sb('matching_joins','DELETE',null,'?id=eq.'+joinId); matchingJoins = matchingJoins.filter(j=>j.id!=joinId); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } renderMatchingList(); showToast('↩ 신청이 취소되었습니다','var(--muted)'); } let emSelectedGames = []; function openEditMatchingModal(matchingId){ const m = matchings.find(x=>x.id==matchingId); if(!m) return; document.getElementById('em_id').value = matchingId; document.getElementById('editMatchingTitle').textContent = `✏ ${formatDateKo(m.match_date)} ${m.meal_type==='lunch'?'점심':'저녁'} 수정`; document.getElementById('em_game_search').value = ''; document.getElementById('em_dropdown').style.display = 'none'; // 기존 게임명 태그로 복원 (쉼표 구분) emSelectedGames = m.game_name ? m.game_name.split(',').map(g=>g.trim()).filter(Boolean) : []; renderEmTags(); document.getElementById('em_time').value = m.match_time||''; document.getElementById('em_location').value = m.location||''; document.getElementById('em_memo').value = m.memo||''; document.getElementById('editMatchingModal').classList.add('open'); } function renderEmDropdown(){ const q = document.getElementById('em_game_search').value.trim().toLowerCase(); const dd = document.getElementById('em_dropdown'); const filtered = games.filter(g=>(!q||g.name.toLowerCase().includes(q))&&!emSelectedGames.includes(g.name)); if(!filtered.length && !q){ dd.style.display='none'; return; } dd.innerHTML = filtered.slice(0,8).map(g=>`
🎲 ${g.name}
`).join('') || `
검색 결과 없음 — 추가 버튼으로 직접 입력
`; dd.style.display = 'block'; } function selectEmGame(name){ if(!emSelectedGames.includes(name)) emSelectedGames.push(name); document.getElementById('em_game_search').value = ''; document.getElementById('em_dropdown').style.display = 'none'; renderEmTags(); } function addEmGame(){ const val = document.getElementById('em_game_search').value.trim(); if(!val) return; if(!emSelectedGames.includes(val)) emSelectedGames.push(val); document.getElementById('em_game_search').value = ''; document.getElementById('em_dropdown').style.display = 'none'; renderEmTags(); } function removeEmGame(name){ emSelectedGames = emSelectedGames.filter(g=>g!==name); renderEmTags(); } function renderEmTags(){ document.getElementById('em_selected_tags').innerHTML = emSelectedGames.map(name=>` 🎲 ${name} `).join(''); } // 수정 모달 드롭다운 외부 클릭 시 닫기 document.addEventListener('click', e=>{ if(!e.target.closest('#editMatchingModal')) return; if(!e.target.closest('#em_game_search') && !e.target.closest('#em_dropdown')){ const dd = document.getElementById('em_dropdown'); if(dd) dd.style.display='none'; } }); async function saveEditMatching(){ const matchingId = document.getElementById('em_id').value; const gameName = emSelectedGames.join(', '); const matchTime = document.getElementById('em_time').value; const location = document.getElementById('em_location').value.trim(); const memo = document.getElementById('em_memo').value.trim(); const update = { game_name:gameName, match_time:matchTime||null, location:location||null, memo:memo||null }; if(isDemo()){ matchings = matchings.map(m=>m.id==matchingId?{...m,...update}:m); localStorage.setItem('bgMatchings', JSON.stringify(matchings)); } else { try{ await sb('matchings','PATCH',update,`?id=eq.${matchingId}`); matchings = matchings.map(m=>m.id==matchingId?{...m,...update}:m); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } closeById('editMatchingModal'); renderMatchingList(); showToast('✅ 수정 완료!','var(--green)'); } async function toggleCloseMatching(matchingId, currentlyClosed){ const newState = !currentlyClosed; if(isDemo()){ matchings = matchings.map(m=>m.id==matchingId?{...m,is_closed:newState}:m); localStorage.setItem('bgMatchings', JSON.stringify(matchings)); } else { try{ await sb('matchings','PATCH',{is_closed:newState},`?id=eq.${matchingId}`); matchings = matchings.map(m=>m.id==matchingId?{...m,is_closed:newState}:m); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } renderMatchingList(); if(newState){ // 마감 시 플레이 기록 바로 추가 유도 const m = matchings.find(x=>x.id==matchingId); const joins = matchingJoins.filter(j=>j.matching_id==matchingId); showToast('🔒 매칭 마감! 플레이 기록을 추가해보세요 🎮','var(--accent)'); // 참가자들에게 푸시 알림 if(!isDemo() && joins.length > 0){ const dateLabel = m?.match_date ? `${m.match_date}${m.match_time ? ' ' + m.match_time : ''}` : (m?.week_label || '매칭'); const gameLabel = m?.game_name ? ` (${m.game_name})` : ''; joins.forEach(j=>{ sendPushNotification( `🔒 매칭 마감 안내`, `${dateLabel}${gameLabel} 매칭이 마감되었습니다.`, j.member_name ); }); } // 참가자가 있으면 플레이 기록 모달 바로 열기 제안 if(joins.length > 0){ setTimeout(()=>{ if(confirm(`매칭 참가자 ${joins.length}명으로 플레이 기록을 바로 추가할까요?`)){ openPlaySelectModalWithPlayers(matchingId); } }, 500); } } else { showToast('🔓 매칭이 재오픈되었습니다','var(--green)'); } } async function deleteMatching(matchingId){ if(!confirm('이 날짜 매칭을 삭제할까요?')) return; if(isDemo()){ matchings = matchings.filter(m=>m.id!=matchingId); matchingJoins = matchingJoins.filter(j=>j.matching_id!=matchingId); localStorage.setItem('bgMatchings', JSON.stringify(matchings)); localStorage.setItem('bgMatchingJoins', JSON.stringify(matchingJoins)); } else { try{ await sb('matchings','DELETE',null,'?id=eq.'+matchingId); matchings = matchings.filter(m=>m.id!=matchingId); matchingJoins = matchingJoins.filter(j=>j.matching_id!=matchingId); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } renderMatchingList(); showToast('🗑 삭제되었습니다','var(--yellow)'); } // ── 댓글·추천 기능 ── async function fetchComments(type, targetId){ if(isDemo()) return JSON.parse(localStorage.getItem(`bgComments-${type}-${targetId}`)||'[]'); try{ const data = await sb('comments','GET',null,`?target_type=eq.${type}&target_id=eq.${targetId}&order=created_at.asc`); return data||[]; }catch(e){ return []; } } async function fetchLikes(type, targetId){ if(isDemo()) return JSON.parse(localStorage.getItem(`bgLikes-${type}-${targetId}`)||'[]'); try{ const data = await sb('likes','GET',null,`?target_type=eq.${type}&target_id=eq.${targetId}`); return data||[]; }catch(e){ return []; } } function renderComments(comments, type, targetId){ if(!comments.length) return `
첫 댓글을 남겨보세요!
`; return comments.map(c=>{ const canDel = currentUser.isAdmin || c.author_name===currentUser.name; const likeCount = c.like_count||0; const likedComment = (c.liked_by||[]).includes(currentUser.name); return `
👤 ${c.author_name} ${(c.created_at||'').slice(0,10)}
${c.content}
${canDel?``:''}
`; }).join(''); } async function submitComment(type, targetId){ const input = document.getElementById(`commentInput-${type}-${targetId}`); const content = input.value.trim(); if(!content){ showToast('댓글을 입력하세요','var(--red)'); return; } const comment = { target_type: type, target_id: targetId, author_id: currentUser.id, author_name: currentUser.name, content, created_at: new Date().toISOString() }; if(isDemo()){ comment.id = Date.now().toString(); const key = `bgComments-${type}-${targetId}`; const list = JSON.parse(localStorage.getItem(key)||'[]'); list.push(comment); localStorage.setItem(key, JSON.stringify(list)); refreshCommentSection(type, targetId, list); } else { try{ const res = await sb('comments','POST', comment); const newComment = res?.[0]||comment; const list = await fetchComments(type, targetId); refreshCommentSection(type, targetId, list); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } input.value=''; } async function deleteComment(commentId, type, targetId){ if(!confirm('댓글을 삭제할까요?')) return; if(isDemo()){ const key = `bgComments-${type}-${targetId}`; const list = JSON.parse(localStorage.getItem(key)||'[]').filter(c=>c.id!=commentId); localStorage.setItem(key, JSON.stringify(list)); refreshCommentSection(type, targetId, list); } else { try{ await sb('comments','DELETE',null,`?id=eq.${commentId}`); const list = await fetchComments(type, targetId); refreshCommentSection(type, targetId, list); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } } function refreshCommentSection(type, targetId, list){ const el = document.getElementById(`commentList-${type}-${targetId}`); if(el) el.innerHTML = renderComments(list, type, targetId); // 댓글 수 업데이트 const title = el?.previousElementSibling; if(title) title.innerHTML = `💬 댓글 ${list.length}`; } async function toggleCommentLike(commentId, type, targetId){ if(isDemo()){ const key = `bgCLikes-${commentId}`; let list = JSON.parse(localStorage.getItem(key)||'[]'); const idx = list.indexOf(currentUser.name); if(idx>=0){ list.splice(idx,1); } else { list.push(currentUser.name); } localStorage.setItem(key, JSON.stringify(list)); updateCommentLikeBtn(commentId, list); return; } try{ const existing = await sb('likes','GET',null,`?target_type=eq.comment&target_id=eq.${commentId}&user_name=eq.${encodeURIComponent(currentUser.name)}`); if(existing&&existing.length){ await sb('likes','DELETE',null,`?id=eq.${existing[0].id}`); } else { await sb('likes','POST',{target_type:'comment', target_id:commentId, user_id:currentUser.id, user_name:currentUser.name}); } const list = await sb('likes','GET',null,`?target_type=eq.comment&target_id=eq.${commentId}`)||[]; updateCommentLikeBtn(commentId, list.map(l=>l.user_name)); }catch(e){ showToast('오류: '+e.message,'var(--red)'); } } function updateCommentLikeBtn(commentId, userList){ const liked = userList.includes(currentUser.name); const btn = document.getElementById(`clikeBtn-${commentId}`); const cnt = document.getElementById(`clikeCnt-${commentId}`); if(btn){ btn.style.background = liked?'rgba(255,107,53,.15)':'none'; btn.style.borderColor = liked?'var(--accent)':'var(--border)'; btn.style.color = liked?'var(--accent)':'var(--muted)'; } if(cnt) cnt.textContent = userList.length; } // ── 투표 기능 ── // ════════════════════════════════════ let votes = []; // [{id, title, desc, multi, deadline, options:[{id,label,voters:[]}], creatorName, createdAt, closed}] let currentVoteFilter = 'all'; // ── 저장/로드 ── function saveVotes(){ if(isDemo()) localStorage.setItem('bgVotes', JSON.stringify(votes)); // Supabase 모드는 각 액션별로 직접 저장 } function loadVotes(){ if(isDemo()) votes = JSON.parse(localStorage.getItem('bgVotes')||'[]'); } function initVotes(){ if(isDemo()){ loadVotes(); if(!votes.length){ votes = [{ id:'v1', title:'4월 모임 날짜 정하기', desc:'오후 2시 시작 예정', multi:false, deadline:'2026-04-10', options:[ {id:'o1',label:'4월 5일 (토)',voters:['김철수','이영희']}, {id:'o2',label:'4월 12일 (토)',voters:['박민수']}, {id:'o3',label:'4월 19일 (토)',voters:[]}, ], creatorName:'관리자', createdAt:'2026-03-11', closed:false },{ id:'v2', title:'다음에 할 게임 투표', desc:'', multi:true, deadline:'', options:[ {id:'o4',label:'카탄',voters:['김철수']}, {id:'o5',label:'아줄',voters:['이영희','박민수']}, {id:'o6',label:'팬데믹',voters:['김철수','이영희']}, ], creatorName:'관리자', createdAt:'2026-03-10', closed:true }]; saveVotes(); } } } // ── 투표 탭 렌더 ── function setVoteFilter(f, el){ currentVoteFilter = f; document.querySelectorAll('#vf-all,#vf-open,#vf-closed').forEach(c=>c.classList.remove('active')); el.classList.add('active'); renderNoticeList(); } function renderVoteList(){ let list = [...votes].reverse(); if(currentVoteFilter==='open') list = list.filter(v=>!v.closed); if(currentVoteFilter==='closed') list = list.filter(v=>v.closed); const el = document.getElementById('voteList'); if(!list.length){ el.innerHTML='
🗳

투표가 없음.
+ 버튼으로 만들어보세요!

'; return; } el.innerHTML = list.map(v=>{ const total = v.options.reduce((s,o)=>s+o.voters.length, 0); const maxVotes = Math.max(...v.options.map(o=>o.voters.length), 1); const myVotedOpts = v.options.filter(o=>o.voters.includes(currentUser.name)); const hasVoted = myVotedOpts.length > 0; const isExpired = v.deadline && new Date(v.deadline) < new Date(); const isClosed = v.closed || isExpired; const bars = v.options.map(o=>{ const pct = total>0 ? Math.round(o.voters.length/total*100) : 0; const isTop = o.voters.length===maxVotes && o.voters.length>0; const isMine = myVotedOpts.some(m=>m.id===o.id); return `
${isMine?'✅ ':''}${o.label}
${o.voters.length}
`; }).join(''); return `
${v.title}
${isClosed?'종료':hasVoted?'✅투표함':'투표하기'}
👤 ${v.creatorName} 📅 ${v.createdAt} ${v.deadline?`⏰ ~${fmtDeadline(v.deadline)}`:''} 🗳 ${total}표 · ${v.multi?'복수선택':'단일선택'}
${bars}
`; }).join(''); } // ── 투표 상세 ── async function openVoteDetail(voteId){ const v = votes.find(x=>x.id===voteId); if(!v) return; const isExpired = v.deadline && new Date(v.deadline) < new Date(); const isClosed = v.closed || isExpired; const total = v.options.reduce((s,o)=>s+o.voters.length, 0); const maxVotes = Math.max(...v.options.map(o=>o.voters.length), 1); const myVotedIds = v.options.filter(o=>o.voters.includes(currentUser.name)).map(o=>o.id); const abstainers = v.abstainers || []; const hasAbstained = abstainers.includes(currentUser.name); const isCreator = v.creatorName === currentUser.name; let optionsHtml = v.options.map(o=>{ const pct = total>0 ? Math.round(o.voters.length/total*100) : 0; const isTop = o.voters.length===maxVotes && o.voters.length>0; const isMine = myVotedIds.includes(o.id); const cls = isClosed?'closed-opt':(isMine?'my-vote':''); if(isClosed || myVotedIds.length>0){ // 결과 보기 return `
${isMine?'✅ ':isTop?'🏆 ':''} ${o.label} ${pct}% (${o.voters.length}표)
${o.voters.length?`
👥 ${o.voters.join(', ')}
`:''}
`; } else { // 투표하기 버튼 return ``; } }).join(''); // 기권 섹션 const abstainHtml = !isClosed && myVotedIds.length===0 ? ` ${hasAbstained&&abstainers.length>0?`
👥 ${abstainers.join(', ')}
`:''} ` : (isClosed && abstainers.length>0 ? `
-기권 ${abstainers.length}명
👥 ${abstainers.join(', ')}
` : ''); // 미투표자 계산 (투표도 기권도 안 한 회원) const allParticipants = new Set([ ...v.options.flatMap(o=>o.voters), ...abstainers ]); const notVoted = members.filter(m => !allParticipants.has(m.name)); const notVotedHtml = notVoted.length > 0 ? `
⏳ 미투표 (${notVoted.length}명)
${notVoted.map(m=>m.name).join(', ')}
` : ''; let actionHtml = ''; if(!isClosed && myVotedIds.length>0){ actionHtml += ``; } if(isCreator || currentUser.isAdmin){ actionHtml += ``; if(isCreator || currentUser.isAdmin){ actionHtml += ``; } } // 댓글 로드 const commentData = await fetchComments('vote', voteId); const liked = false; document.getElementById('voteDetailContent').innerHTML = `
${v.title}
${isClosed?'종료':'진행중'}
${v.desc?`
${v.desc}
`:''}
👤 ${v.creatorName} ${v.deadline?`⏰ 마감 ${v.deadline}${isExpired?' (만료)':''}`:''} 🗳 총 ${total}표 · ${v.multi?'복수선택':'단일선택'}
${optionsHtml}${abstainHtml||''}${notVotedHtml||''}
${actionHtml?`
${actionHtml}
`:''}
💬 댓글 ${commentData.length}
${renderComments(commentData,'vote',voteId)}
`; document.getElementById('voteDetailModal').classList.add('open'); } // ── 투표하기 ── async function castVote(voteId, optId, e){ e && e.stopPropagation(); const v = votes.find(x=>x.id===voteId); if(!v) return; const isClosed = v.closed || (v.deadline && v.deadline < today()); if(isClosed){ showToast('종료된 투표입니다.','var(--muted)'); return; } const myVotedIds = v.options.filter(o=>o.voters.includes(currentUser.name)).map(o=>o.id); if(!v.multi && myVotedIds.length>0){ v.options.forEach(o=>{ o.voters = o.voters.filter(n=>n!==currentUser.name); }); } const opt = v.options.find(o=>o.id===optId); if(!opt) return; if(v.multi && opt.voters.includes(currentUser.name)){ opt.voters = opt.voters.filter(n=>n!==currentUser.name); showToast('선택 취소','var(--muted)'); } else { opt.voters.push(currentUser.name); showToast('✅ 투표 완료!','var(--green)'); // 투표 생성자에게 알림 (본인 제외) if(!isDemo() && v.created_by_name && v.created_by_name !== currentUser.name){ sendPushNotification( `🗳 새 투표 참여`, `${currentUser.name}님이 "${v.title}" 투표에 참여했습니다.`, v.created_by_name ); } } if(!isDemo()) await sb('votes','PATCH',{options:v.options.map(o=>({...o,votes:o.voters}))},`?id=eq.${voteId}`).catch(()=>{}); else saveVotes(); renderNoticeList(); openVoteDetail(voteId); } // ── 기권 ── async function castAbstain(voteId){ const v = votes.find(x=>x.id===voteId); if(!v) return; const isClosed = v.closed || (v.deadline && new Date(v.deadline) < new Date()); if(isClosed){ showToast('종료된 투표입니다.','var(--muted)'); return; } if(!v.abstainers) v.abstainers = []; if(v.abstainers.includes(currentUser.name)){ v.abstainers = v.abstainers.filter(n=>n!==currentUser.name); showToast('기권 취소','var(--muted)'); } else { // 기존 투표 취소 후 기권 v.options.forEach(o=>{ o.voters = o.voters.filter(n=>n!==currentUser.name); }); v.abstainers.push(currentUser.name); showToast('기권 처리됨','var(--muted)'); } // __abstain__ 옵션으로 저장 const allOpts = [ ...v.options.map(o=>({...o, votes:o.voters})), {id:'__abstain__', label:'기권', votes:v.abstainers} ]; if(!isDemo()) await sb('votes','PATCH',{options:allOpts},`?id=eq.${voteId}`).catch(()=>{}); else saveVotes(); renderNoticeList(); openVoteDetail(voteId); } // ── 투표 취소 ── async function cancelVote(voteId){ const v = votes.find(x=>x.id===voteId); if(!v) return; v.options.forEach(o=>{ o.voters = o.voters.filter(n=>n!==currentUser.name); }); if(!isDemo()) await sb('votes','PATCH',{options:v.options.map(o=>({...o,votes:o.voters}))},`?id=eq.${voteId}`).catch(()=>{}); else saveVotes(); renderNoticeList(); closeById('voteDetailModal'); showToast('↩ 투표 취소됨','var(--muted)'); } // ── 투표 종료/재개 ── async function closeVote(voteId){ const v = votes.find(x=>x.id===voteId); if(!v) return; v.closed = true; if(!isDemo()) await sb('votes','PATCH',{is_closed:true},`?id=eq.${voteId}`).catch(()=>{}); else saveVotes(); renderNoticeList(); closeById('voteDetailModal'); showToast('🔒 투표 종료','var(--muted)'); // 투표자 전원에게 결과 알림 if(!isDemo()){ const voters = [...new Set(v.options.flatMap(o=>o.voters||[]))]; // 결과 1위 옵션 const topOpt = [...v.options].sort((a,b)=>(b.voters?.length||0)-(a.voters?.length||0))[0]; const resultMsg = topOpt ? `결과: "${topOpt.label}" (${topOpt.voters?.length||0}표)` : '투표가 종료되었습니다.'; voters.forEach(name=>{ sendPushNotification(`🔒 투표 종료: ${v.title}`, resultMsg, name); }); } } async function reopenVote(voteId){ const v = votes.find(x=>x.id===voteId); if(!v) return; v.closed = false; v.deadline = ''; if(!isDemo()) await sb('votes','PATCH',{is_closed:false, deadline:null},`?id=eq.${voteId}`).catch(()=>{}); else saveVotes(); renderNoticeList(); closeById('voteDetailModal'); showToast('🔓 투표 재개','var(--green)'); } // ── 투표 삭제 ── async function deleteVote(voteId){ if(!confirm('투표를 삭제하시겠습니까?')) return; votes = votes.filter(x=>x.id!==voteId); if(!isDemo()) await sb('votes','DELETE',null,`?id=eq.${voteId}`).catch(()=>{}); else saveVotes(); renderNoticeList(); closeById('voteDetailModal'); showToast('🗑 삭제 완료','var(--muted)'); } // ── 투표 만들기 모달 ── function updateMultiStyle(){ const isMulti = document.getElementById('cv_multi_yes').checked; document.getElementById('cv_multi_no_label').style.borderColor = isMulti ? 'var(--border)' : 'var(--accent)'; document.getElementById('cv_multi_yes_label').style.borderColor = isMulti ? 'var(--accent)' : 'var(--border)'; } function openCreateVoteModal(){ document.getElementById('cv_title').value=''; document.getElementById('cv_desc').value=''; document.getElementById('cv_deadline').value=''; document.getElementById('cv_multi_no').checked=true; updateMultiStyle(); const wrap = document.getElementById('cv_options_wrap'); wrap.innerHTML=''; addVoteOptionRow(''); addVoteOptionRow(''); document.getElementById('createVoteModal').classList.add('open'); } function addVoteOptionRow(defaultVal=''){ const wrap = document.getElementById('cv_options_wrap'); const div = document.createElement('div'); div.style.cssText='display:flex;gap:6px;align-items:center;'; div.innerHTML=` `; wrap.appendChild(div); } async function createVote(){ const title = document.getElementById('cv_title').value.trim(); if(!title){ showToast('투표 제목을 입력하세요.','var(--red)'); return; } const opts = [...document.querySelectorAll('#cv_options_wrap input')].map(i=>i.value.trim()).filter(Boolean); if(opts.length < 2){ showToast('선택 항목을 2개 이상 입력하세요.','var(--red)'); return; } const multi = document.querySelector('input[name="cv_multi"]:checked').value === 'yes'; const deadline = document.getElementById('cv_deadline').value; const desc = document.getElementById('cv_desc').value.trim(); const options = opts.map((label,i)=>({id:'o'+Date.now()+i, label, voters:[], votes:[]})); const newVote = { title, desc, multi, deadline, options, creatorName:currentUser.name, createdAt:today(), closed:false }; if(!isDemo()){ try{ const res = await sb('votes','POST',{ title, description:desc, options, is_closed:false, created_by_name:currentUser.name, created_by:currentUser.id||null, deadline: deadline ? new Date(deadline).toISOString() : null, multi }); const v = res?.[0]; votes.push({...newVote, id:v?.id||'v'+Date.now()}); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } else { votes.push({...newVote, id:'v'+Date.now()}); saveVotes(); } closeById('createVoteModal'); renderNoticeList(); showToast('🗳 투표 생성 완료!','var(--green)'); // 전체 회원에게 푸시 알림 if(!isDemo()){ sendPushNotification(`🗳️ 새 투표: ${title}`, `"${title}" 투표에 참여해주세요!`, 'all'); } } // ── 내 정보 ── function renderMyInfo(){ const el = document.getElementById('myInfoDisplay'); el.innerHTML = `
이름${currentUser.name}
지역${currentUser.region||'-'}
부서${currentUser.dept}
연락처${currentUser.phone||'-'}
권한${currentUser.isAdmin?'👑 관리자':'일반 회원'}
가입일${currentUser.joinedAt||'-'}
`; // 알림 버튼: SW 구독 상태 실시간 체크 후 렌더 if('serviceWorker' in navigator){ navigator.serviceWorker.ready.then(reg=> reg.pushManager.getSubscription() ).then(sub=>{ window._pushSub = sub || null; renderPushBtn(); }).catch(()=>renderPushBtn()); } else { renderPushBtn(); } // 홈화면 추가 버튼 상태 체크 checkInstallSupport(); } function openMyEditModal(){ // 초기화 document.getElementById('edit_cur_pw').value=''; document.getElementById('edit_new_pw').value=''; document.getElementById('edit_new_pw2').value=''; document.getElementById('myEditStep1').style.display='block'; document.getElementById('myEditStep2').style.display='none'; document.getElementById('myEditModal').classList.add('open'); } async function verifyEditPw(){ const pw = document.getElementById('edit_cur_pw').value; if(!pw){ showToast('비밀번호를 입력하세요','var(--red)'); return; } if(isDemo()){ if(pw !== currentUser.pw){ showToast('비밀번호가 틀렸습니다','var(--red)'); return; } } else { try{ const hash = await sha256(pw); const res = await sb('members','GET',null,`?id=eq.${currentUser.id}&password_hash=eq.${hash}`); if(!res||!res.length){ showToast('비밀번호가 틀렸습니다','var(--red)'); return; } }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } // 현재 정보 채우기 document.getElementById('edit_name').value = currentUser.name||''; document.getElementById('edit_region').value = currentUser.region||''; document.getElementById('edit_dept').value = currentUser.dept||''; document.getElementById('edit_phone').value = currentUser.phone||''; document.getElementById('myEditStep1').style.display='none'; document.getElementById('myEditStep2').style.display='block'; } async function saveMyEdit(){ const name = document.getElementById('edit_name').value.trim(); const region = document.getElementById('edit_region').value; const dept = document.getElementById('edit_dept').value.trim(); const phone = formatPhone(document.getElementById('edit_phone').value.trim()); const newPw = document.getElementById('edit_new_pw').value; const newPw2 = document.getElementById('edit_new_pw2').value; if(!name){ showToast('이름을 입력하세요','var(--red)'); return; } if(!region){ showToast('지역을 선택하세요','var(--red)'); return; } if(!dept){ showToast('부서를 입력하세요','var(--red)'); return; } if(newPw && newPw !== newPw2){ showToast('새 비밀번호가 일치하지 않습니다','var(--red)'); return; } if(isDemo()){ const m = members.find(x=>x.id===currentUser.id); if(m){ m.name=name; m.region=region; m.dept=dept; m.phone=phone; if(newPw) m.pw=newPw; } currentUser.name=name; currentUser.region=region; currentUser.dept=dept; currentUser.phone=phone; if(newPw) currentUser.pw=newPw; localStorage.setItem('bgMembers', JSON.stringify(members)); localStorage.setItem('bgCurrentUser', JSON.stringify(currentUser)); } else { try{ const patch = {name, region, department:dept, phone:phone||null}; if(newPw) patch.password_hash = await sha256(newPw); await sb('members','PATCH',patch,`?id=eq.${currentUser.id}`); currentUser.name=name; currentUser.region=region; currentUser.dept=dept; currentUser.phone=phone; localStorage.setItem('bgCurrentUser', JSON.stringify(currentUser)); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } closeById('myEditModal'); renderMyInfo(); document.getElementById('userChip').textContent = `👤 ${currentUser.name}${currentUser.isAdmin?'(관)':''}`; showToast('✅ 회원정보가 수정되었습니다','var(--green)'); } function openMyPwModal(){ ['mypw_cur','mypw_new','mypw_confirm'].forEach(id=>document.getElementById(id).value=''); document.getElementById('myPwModal').classList.add('open'); } async function changeMyPassword(){ const cur = document.getElementById('mypw_cur').value; const nw = document.getElementById('mypw_new').value; const cf = document.getElementById('mypw_confirm').value; if(!nw){ showToast('새 비밀번호를 입력하세요','var(--red)'); return; } if(nw !== cf){ showToast('새 비밀번호가 일치하지 않음','var(--red)'); return; } if(isDemo()){ if(cur !== currentUser.pw){ showToast('현재 비밀번호가 틀림','var(--red)'); return; } const m = members.find(x=>x.id===currentUser.id); if(m) m.pw = nw; currentUser.pw = nw; localStorage.setItem('bgMembers', JSON.stringify(members)); localStorage.setItem('bgCurrentUser', JSON.stringify(currentUser)); } else { try{ const curHash = await sha256(cur); const check = await sb('members','GET',null,`?id=eq.${currentUser.id}&password_hash=eq.${curHash}`); if(!check||!check.length){ showToast('현재 비밀번호가 틀림','var(--red)'); return; } const newHash = await sha256(nw); await sb('members','PATCH',{password_hash:newHash},`?id=eq.${currentUser.id}`); }catch(e){ showToast('오류: '+e.message,'var(--red)'); return; } } closeById('myPwModal'); showToast('✅ 비밀번호 변경 완료','var(--green)'); }